Головна / Статті

Керування агентами для програмування на AI за допомогою чотирьох названих патернів архітектури React

Дізнайтеся про чотири шаблони React (композиція секцій, користувацькі хуків, поліморфні компоненти, редюсери) та про те, як правильне найменування їх у запитах допомагає отримувати більш чистий код, створений штучним інтелектом.

Видавець: REACTAPP.TOP12 хв читання2819 слів

Джерела: How I Get AI to Write Cleaner React Code with 4 Architecture Patterns

Агенти для програмування на основі ШІ, такі як Claude чи OpenCode, можуть створити функціональний елемент React за кілька запитів, і код зазвичай робить те, про що ви просите. Слабкою стороною є структура: генеровані компоненти часто налічують сотні рядків, поєднуючи маркування, дані, ефекти та обробники в одному файлі, а прохання до агента „зробити його модульним“ зазвичай призводить до створення фрагментів, специфічних для конкретної сторінки, а не справді повторно використовуваних частин. У цій статті розглядаються чотири підходи, які вирішують цю проблему зі структурою, а також показано, як називати їх у запитах, щоб агент будував саме ту архітектуру, яку ви плануєте, а не обирав її замість вас.

Чому функціональний код — це не те саме, що хороша архітектура

Поширеним принципом чистого коду є те, що компонент React має складатися приблизно з 100 рядків, причому практичним максимумом вважається близько 200 рядків. Це скоріше гіпотеза, ніж правило, але файли з 500 рядками чи більше — саме там результати роботи ШІ починають відхилятися, якщо їх залишити без контролю.

Очевидним рішенням є прохання агента розділити код на частини. Він зазвичай погоджується, розділяючи інтерфейс на кілька менших компонентів у папці, присвятованій цій сторінці. Ці частини є меншими, але все одно пов’язані з однією функціональністю та рідко можуть використовуватися в інших місцях. Це розкладання, а не модульність.

Для невеликого проекту це майже не має значення. Але для програмного забезпечення, яке має розвиватися протягом багатьох років, кожна зміна впливає на код, який ніколи не був створений для повторного використання чи розширення. Рішення полягає не у припиненні використання ШІ, а у знанні шаблонів проектування, які дозволяють кодбазі React рости, та у керуванні агентом за допомогою цих шаблонів. Генерація коду стає дешевою; саме ваш вибір щодо його структурування залишається важливим. Якщо ви хочете дізнатися більше про те, як результати роботи ШІ іноді ігнорують принципи чистого коду, перегляньте статтю про те, коли ШІ пише ваш React-додаток, але ігнорує принципи чистого коду.

Шаблон 1: розділити секції на чотири компоненти

Якщо попросити фахівця створити розділ інтерфейсу, він зазвичай об’єднає обгортку, заголовок, відображення списку та окремі елементи в один компонент. Коли додаток розвивається, це дублювання стає очевидним, адже та сама структура зустрічається майже скрізь: блоги, інтернет-магазини, портфоліо, каталоги, лендінг-сторінки та панелі керування SaaS.

  • обгортка розділу
  • заголовок розділу
  • список карток
  • одна картка

Розділ

Розділ є батьківським елементом. Він отримує дані через параметри та передає їх своїм дочірнім елементам, тож знає про їхню структуру, але не про деталі стилювання.

export function ProductSection({ title, subtitle, products }) {
  return (
    <section>
      <SectionHeading
        title={title}
        subtitle={subtitle}
      />

      <ProductList products={products} />
    </section>
  )
}

Заголовок

Спеціалізований компонент заголовка зберігає стилізацію заголовка та підзаголовка в одному місці, тож кожен розділ додатку виглядає послідовно, без необхідності повторного створення маркапу.

export function SectionHeading({ title, subtitle }) {
  return (
    <div>
      <h2>{title}</h2>
      <p>{subtitle}</p>
    </div>
  )
}

Список

Список має єдину функцію: перебирати елементи колекції та відображати картку для кожного з них із стабільним значенням key.

export function ProductList({ products }) {
  return (
    <div className="grid">
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
        />
      ))}
    </div>
  )
}

Зауважте, що він не має уявлення про те, як виглядає продукт. Він знає лише, як відображати список, що полегшує його подальше використання з іншою карткою.

Картка

Картка відображає лише один елемент та нічого більше.

export function ProductCard({ product }) {
  return (
    <div>
      <img src={product.image} />
      <h3>{product.title}</h3>
      <p>${product.price}</p>
    </div>
  )
}

Єдине, що його хвилює — це візуальне представлення. У реальному коді також слід надати зображенню змістовного атрибута alt, якого не враховано у цьому мінімальному прикладі.

Порівняння двох підходів

Якщо залишити все за замовчуванням, агент схильний створювати щось на кшталт цього:

function Products({ products }) {
  return (
    <section>
      <h2>Latest Products</h2>

      <div className="grid">
        {products.map(product => (
          <article key={product.id}>
            <img src={product.image} />
            <h3>{product.title}</h3>
            <p>${product.price}</p>
          </article>
        ))}
      </div>
    </section>
  )
}

З технічної точки зору з цим усе гаразд. Проблеми виникають пізніше, коли інша сторінка потребує такого ж стилю заголовка, дизайну картки чи сітки. У такому випадку доводиться або дублювати маркап, або виділяти компоненти з кодової бази, яка вже сформувалася навколо цих дублікатів.

За допомогою розділення на чотири компоненти сама сторінка складається з даних та їх композиції:

function HomePage() {
  return (
    <>
      <ProductSection
        title="Hot Products"
        subtitle="Most popular products right now"
        products={hotProducts}
      />

      <ProductSection
        title="New Products"
        subtitle="Recently added to our store"
        products={newProducts}
      />

      <ProductSection
        title="Trending Products"
        subtitle="What everyone is buying"
        products={trendingProducts}
      />

      <ProductSection
        title="Discounted Products"
        subtitle="Best deals this week"
        products={discountedProducts}
      />
    </>
  )
}

Структура файлу залишається компактною та передбачуваною:

components/
│
├── SectionHeading.tsx
├── ProductCard.tsx
├── ProductList.tsx
└── ProductSection.tsx

Кожен будівельний блок тепер має чітке призначення. Той самий заголовок підходить у будь-якому розділі, ще один рядок з продуктом — це просто ще один елемент ProductSection, а зовсім нову сторінку можна скласти з тих самих частин.

Запрошення до використання

Замість того, щоб просити агента створити розділ продукту, детально описайте структуру: «Використовуйте тут шаблон UI з 4 компонентами: ProductSection, який складається з SectionHeading та ProductList, що відображає елементи ProductCard, причому кожен компонент виконує лише одну функцію». Це одне речення змінює форму результату, і він стає набагато простішим для читання, повторного використання та підтримки.

Шаблон 2: переміщення логіки до власних хуків

Більшість розробників React знають про власні хуки, проте коли це робить агент, їх легко забути. Коли проситься створити новий екран, агент зазвичай залишає стан, ефекти, виклики API та обробники подій всередині компонента. Через кілька ітерацій файл досягає 300 рядків через переплетення UI та логіки.

Іноді цілком доречно зберігати логіку всередині компонента. Однак коли така сама логіка потрібна в іншому місці або починає ускладнювати розуміння того, що відображає компонент, краще виділити її у окремий хук. Типовими прикладами є:

  • useDebounce() для затримки введення пошуку перед надсиланням запиту
  • useApi() для обробки отримання даних, а також станів завантаження та помилок
  • useLocalStorage() для зберігання стану в браузері
  • useClickOutside() для закриття випадаючих списків чи модалок при кліку користувача в інше місце
  • usePagination() для керування станом сторінок
  • useToggle() для керування булевими станами, наприклад відкриттям бічної панелі

Логіка пошуку всередині компонента

Розгляньмо поле пошуку продуктів. Типова реалізація зберігає як первинний запит, так і оброблений варіант у стані, використовує один ефект із таймером для оновлення обробленого значення та другий ефект для отримання даних щоразу, коли воно змінюється:

function SearchProducts() {
  const [query, setQuery] = useState("");
  const [debouncedQuery, setDebouncedQuery] = useState("");

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 500);

    return () => clearTimeout(timer);
  }, [query]);

  useEffect(() => {
    if (!debouncedQuery) return;

    fetch(`/api/products?search=${debouncedQuery}`);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search products..."
    />
  )
}

Це працює. Але якщо три екрани потребують однакової логіки затримки, доводиться або копіювати її тричі, або пізніше витрачати час та ресурси на її вилучення. Існують ще два моменти, пов’язані з надійністю: запит слід передавати через encodeURIComponent перед вставкою у URL, а справжній запит має обробляти відповіді, надіслані не у правильному порядку, наприклад за допомогою AbortController під час очищення ефекту.

Вилучення гака затримки

Логіка таймера переміщується у власний гак, який приймає значення та затримку та повертає оброблене значення:

// hooks/useDebounce.ts

export function useDebounce(value, delay = 500) {
  const [debouncedValue, setDebouncedValue] = useState(value)

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => clearTimeout(timer)
  }, [value, delay])

  return debouncedValue
}

Функція очищення скасовує запланований таймер щоразу, коли значення змінюється до закінчення затримки, саме тому це є механізмом дебаунсингу, а не просто затримкою. Включення delay до масиву залежностей допомагає зберегти коректність хука, якщо його змінює користувач.

Потім компонент зводиться до своєї справжньої функції:

function SearchProducts() {
  const [query, setQuery] = useState("")

  const debouncedQuery = useDebounce(query)

  useEffect(() => {
    if (!debouncedQuery) return

    fetch(`/api/products?search=${debouncedQuery}`)
  }, [debouncedQuery])

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
    />
  );
}

Відображення залишається в компоненті, а механізм дебаунсингу — у хуку, причому кожен з них виконує одну функцію.

Коли хук є доцільним

Не кожен виклик useState() потребує власного хука. Хорошими критеріями є:

  • однакова логіка зустрічається або буде зустрічатися в кількох компонентах;
  • компонент стає занадто довгим;
  • функціонал поєднує кілька викликів useState(), ефектів або обробників подій;
  • API-виклики, форми, пошук, сторінкування, механізми автентифікації чи код для зберігання даних заважають логіці відображення;
  • ви хочете тестувати логіку незалежно від інтерфейсу користувача.
  • Запит про це

    Оскільки агенти за замовчуванням тримають логіку безпосередньо в компонентах, чітко це зазначте: „Покладіть повторно використовувану логіку для цієї функції у спеціальні хуки, наприклад useApi(), useDebounce() чи useLocalStorage(), а компонент нехай відповідає лише за форматування.“ Це загальне правило діє у всіх чотирьох паттернах: чим більше архітектурного контексту ви надасте, тим кращим буде створений код.

    Паттерн 3: один поліморфний компонент замість майже ідентичних

    Метою повторно використовуваних компонентів є не збільшення їх кількості, а уникнення дублікатів. Агенти часто створюють сім’ї таких компонентів:

    Button.tsx
    LinkButton.tsx
    IconButton.tsx
    

    Вони мають майже однаковий стиль. Різниця полягає у елементі, який вони відображають: іноді справжній <button>, іноді посилання, іноді посилання-маршрутизатор, а іноді просто елемент з іконкою. Паттерн поліморфізму замінює цю групу на один компонент, який може відображатися у вигляді будь-якого елемента, який потрібен у конкретній ситуації.

    Дубльована версія

    Типова генерована пара виглядає так:

    // Button.tsx
    export function Button({ children }) {
      return (
        <button className="btn">
          {children}
        </button>
      )
    }
    
    // LinkButton.tsx
    export function LinkButton({ href, children }) {
      return (
        <a href={href} className="btn">
          {children}
        </a>
      )
    }
    

    Стилі повторюються лише тому, що змінився базовий HTML-елемент.

    Поліморфний Button у TypeScript

    Версія, створена шляхом введення тексту, приймає необов’язкове значення властивості as, типом якого є універсальний ElementType з значенням за замовчуванням "button". Решта її властивостей формуються на основі елемента чи компонента, на який вказує as, за допомогою ComponentPropsWithoutRef; саме значення as опускається, щоб уникнути конфліктів.

    type ButtonProps<E extends ElementType = "button"> = {
      as?: E
      children: ReactNode
      variant?: "primary" | "outline"
    } & Omit<React.ComponentPropsWithoutRef<E>, "as">
    
    const Button = <E extends ElementType = "button">({
      as,
      children,
      variant = "primary",
      ...rest
    }: ButtonProps<E>) => {
      const Component = as || "button"
    
      return (
        <Component {...rest}>
          {children}
        </Component>
      )
    }
    

    Оскільки типи властивостей відповідають E, передача значення as="a" робить властивість href дійсною, а передача об’єкта типу Link від роутера — його властивість to, причому у всіх випадках відбувається перевірка типу. У прикладі показано лише основну частину: властивість variant приймається, але ще не застосовується, тож у повній версії вона мала б бути прив’язана до імені класу та поєднана з будь-якою властивістю className, яка була передана. Також доцільно встановити значення за замовчуванням type="button" під час відображення нативної кнопки, щоб вона випадково не надсилала форму, яка знаходиться поруч.

    Цей же компонент тепер покриває всі випадки:

    <Button>
      Save
    </Button>
    
    <Button
      variant="outline"
    >
      Cancel
    </Button>
    
    <Button
      as="a"
      href="/pricing"
    >
      Pricing
    </Button>
    
    <Button
      as={Link}
      to="/dashboard"
    >
      Dashboard
    </Button>
    
    <Button
      as="button"
      onClick={handleDelete}
    >
      Delete
    </Button>
    

    Функції „Зберегти“, „Скасувати“, посилання на ціни, маршрут дашборду та операція видалення мають спільний дизайн; змінюється лише елемент чи поведінка, які відображаються.

    Коли його використовувати

    Простий тест: якщо інтерфейс залишається незмінним, але базовий елемент змінюється, цей компонент є гарним кандидатом на поліморфність. Поширені приклади:

    • кнопки, які іноді виконують функцію посилань
    • компоненти типографіки, які відображають h1, h2, p або span
    • карти, які іноді є article, а іноді — клікабельним посиланням
    • контейнери макетування, які відображаються як section, div або main

    Це не є індивідуальною пристрастю. Бібліотеки компонентів, такі як Radix UI, Chakra UI та Flexy UI, значною мірою спираються на цю саму ідею для створення гнучких систем дизайну.

    Запрошення до цього

    Агенти схильні створювати новий компонент для кожної незначної зміни, тому надайте їм шаблон із поясненням: «Напишіть один поліморфний Button, який приймає властивість as та може відображати нативну button, тег a або елемент React Router Link; обробляйте різні варіанти та розміри всередині цього єдиного компонента, замість того щоб створювати нові». У результаті у вас буде менше компонентів, менше дублювання та більш послідовна система дизайну.

    Шаблон 4: об’єднати пов’язані стани за допомогою редьюсера

    Це, мабуть, той шаблон, який агенти найчастіше ігнорують. Їхнім стандартним підходом є окремий useState() для кожного значення, що підходить для простих компонентів. Однак у міру розширення функціоналу доводиться керувати п’ятьма чи шістьма елементами стану, які залежать один від одного:

    const [loading, setLoading] = useState(false);
    const [products, setProducts] = useState([]);
    const [error, setError] = useState(null);
    const [page, setPage] = useState(1);
    const [search, setSearch] = useState("");
    const [sortBy, setSortBy] = useState("latest");
    

    Коли вже діють пагінація, фільтрація, сортування, завантаження та обробка помилок, одна дія користувача зазвичай мусить оновлювати кілька з цих функцій одночасно, і компонент стає складним для розуміння.

    Координовані сеттери

    Ось як виглядає запит до даних із окремими станами:

    setLoading(true)
    
    try {
      const data = await fetchProducts()
    
      setProducts(data)
      setError(null)
    } catch {
      setError("Something went wrong")
    } finally {
      setLoading(false)
    }
    

    Кілька сеттерів запускаються одночасно для однієї логічної події. Саме це згрупування є класичним сигналом того, що редюсер краще виразить цю логіку.

    Моделювання переходів як дій

    За допомогою редюсера ви описуєте форму стану один раз та перелічуєте можливі переходи. Синтаксис буде знайомим, якщо ви користувалися Redux:

    const initialState = {
      loading: false,
      products: [],
      error: null,
    };
    
    function reducer(state, action) {
      switch (action.type) {
        case "FETCH_START":
          return {
            ...state,
            loading: true,
          };
    
        case "FETCH_SUCCESS":
          return {
            loading: false,
            products: action.payload,
            error: null,
          };
    
        case "FETCH_ERROR":
          return {
            ...state,
            loading: false,
            error: action.payload,
          };
    
        default:
          return state;
      }
    }
    

    FETCH_START встановлює флаг завантаження, FETCH_SUCCESS зберігає товари та скасовує як статус завантаження, так і помилку, а FETCH_ERROR фіксує помилку. У випадку успіху повертається повний новий об’єкт; якщо пізніше до стану додати такі поля, як page чи sortBy, не забудьте використати там ...state, інакше ці значення будуть втрачені.

    Потім компонент відправляє дії замість того, щоб викликати функції-змінники:

    const [state, dispatch] = useReducer(
      reducer,
      initialState
    )
    
    dispatch({ type: "FETCH_START" })
    
    dispatch({
      type: "FETCH_SUCCESS",
      payload: products,
    })
    

    Кожне оновлення тепер проходить через одну функцію. Щоб зрозуміти, як працює ця функціональність, потрібно читати редюсер, замість того щоб шукати виклики setState у всьому компоненті, і оскільки редюсер є чистою функцією, його легко протестувати як окремий модуль.

    Коли підходить редюсер

    Редюсери не підходять для кожного компонента. Вони є корисними у таких випадках:

    • кілька змін стану відбуваються одночасно;
    • функція має багато окремих дій;
    • переходи між станами стають складними для відстеження;
    • функція є кошиком, багатокроковим майстерним інструментом, процесом входу, великою формою або насиченим панеллю керування.

    Для модальних вікон чи перемикачів простий useState() залишається кращим вибором.

    Запит у користувача

    Без керівництва агент майже завжди буде створювати низку викликів useState(). Запитайте про бажану структуру: „Реалізуйте це за допомогою useReducer: визначте іменовані типи дій, розмістіть кожну зміну стану всередині функції-редуктора та нехай компонент лише відображатиме контент та надсилатиме команди.“ Чим більша функція, тим більше це приносить користі.

    Основні висновки

    • Штучні інтелектуальні агенти надійно створюють функціональний код на React, але зазвичай використовують монолітичні компоненти, логіку безпосередньо в коді, майже ідентичні варіанти та розрізнений стан.
    • Чотири патерни допомагають подолати ці тенденції: композиція розділів, користувацькі хуки, поліморфні компоненти та редюсери.
    • У кожного патерна є чіткий тригер; застосовуйте його, коли з’являється цей тригер, а не інстинктивно.
    • Назва патерна та опис елементів, яких ви очікуєте у своєму запиті, — це найдешевший спосіб отримати кращу архітектуру від агента.
    • Створення коду стає все простішим з кожним роком; вирішення питання про його структуру залишається завданням розробника, а знання цих патернів допомагає це зробити.