Управление агентами для кодирования с ИИ с помощью четырех специфицированных паттернов архитектуры React
Изучите четыре шаблона React (композиция секций, пользовательские хуки, полиморфные компоненты, редьюсеры) и то, как правильное называние этих элементов в запросах позволяет получать более чистый код, сгенерированный ИИ.
Источники: 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(), эффектов или обработчиков событий;
Просьба об этом
Поскольку агенты по умолчанию сохраняют логику внутри компонентов, ясно укажите это: «Поместите поведение, которое можно использовать несколько раз для данной функции, в пользовательские хуки, например 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 и может отрисовывать нативную кнопку, тег a или элемент Link из React Router; обрабатывайте варианты и размеры внутри этого единственного компонента, а не создавайте новые». В итоге у вас будет меньше компонентов, меньше дублирования и более целостная система дизайна.
Шаблон 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");
Когда в игру вступают пагинация, фильтрация, сортировка, загрузка и обработка ошибок, одно действие пользователя обычно требует одновременной корректировки нескольких из этих параметров, из-за чего компонент становится сложным для понимания.
Скоординированные методы-установщики
Вот как выглядит операция fetch при использовании отдельных состояний:
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, но по умолчанию используют монолитные компоненты, логику внутри кода, почти идентичные варианты и разрозненное управление состоянием.
- Четыре паттерна помогают преодолеть эти недостатки: композиция секций, пользовательские хуки, полиморфные компоненты и редьюсеры.
- У каждого паттерна есть четкий триггер; его следует применять, когда появляется этот триггер, а не инстинктивно.
- Указание названия паттерна и элементов, которые вы ожидаете в своем запросе, — это самый простой способ получить более качественную архитектуру от агента.
- Генерация кода становится все проще с каждым годом; решение о том, как он должен быть структурирован, по-прежнему остается задачей разработчика, и знание этих паттернов помогает в этом.