Kierowanie agentami kodującymi opartymi na AI za pomocą czterech nazwanych wzorców architektury React
Naucz się czterech wzorców React (składanie sekcji, niestandardowe hooki, komponenty polimorficzne, reduktory) oraz tego, jak nadawanie im nazw w zapytaniach zapewnia czystszy kod generowany przez AI.
Źródła: How I Get AI to Write Cleaner React Code with 4 Architecture Patterns
Agenci do programowania oparte na AI, takie jak Claude czy OpenCode, potrafią stworzyć działającą funkcję w Reactie przy użyciu zaledwie kilku instrukcji, a kod zazwyczaj robi to, o co prosisz. Słabym punktem jest struktura: generowane komponenty często liczą setki linii, łącząc w jednym pliku tagi, dane, efekty i obsługi zdarzeń, a prośba o „stworzenie czegoś modułowego” zwykle skutkuje fragmentami specyficznymi dla danej strony, a nie elementami naprawdę wielokrotnie używalnymi. Ten artykuł omawia cztery wzorce, które poprawiają tę strukturę, a co równie ważne, pokazuje, jak nazwać je w instrukcjach, aby agent budował architekturę zgodną z twoimi intencjami, a nie wybierał ją za ciebie.
Dlaczego działający kod to nie to samo co dobra architektura
Powszechną zasadą pisania czystego kodu jest utrzymywanie komponentu React na poziomie około 100 linii, przy czym praktycznym limitem jest około 200 linii. Jest to raczej heurystyka niż reguła obowiązująca we wszystkich przypadkach, ale pliki składające się z 500 linii lub więcej to dokładnie sytuacja, w której wyniki generowane przez sztuczną inteligencję tracą spójność, jeśli zostaną tak pozostawione.
Oczywistym rozwiązaniem jest poproszenie agenta o podzielenie kodu na mniejsze części. Zazwyczaj spełnia to prośbę, dzieląc interfejs użytkownika na kilka mniejszych komponentów umieszczonych w folderze przeznaczonym dla danej strony. Te fragmenty są mniejsze, ale nadal odnoszą się do jednej funkcjonalności i rzadko mogą być wykorzystane gdzie indziej. To jest dekompozycja, a nie modularność.
Dla małego projektu to prawie nie ma znaczenia. Dla oprogramowania, które musi rozwijać się przez lata, oznacza to, że każda zmiana wpływa na kod, który nigdy nie został zaprojektowany tak, by można go było ponownie wykorzystać lub rozszerzyć. Rozwiązaniem nie jest zaprzestanie używania AI, lecz znajomość wzorców projektowych, które umożliwiają skalowanie bazy kodu React, oraz kierowanie agentem za pomocą tych wzorców. Generowanie kodu staje się coraz tańsze; to Twoja ocena tego, jak go zorganizować, pozostaje kluczowa. Jeśli chcesz szerszego spojrzenia na to, jak wyniki generowane przez AI często pomijają zasady czystego kodu, przeczytaj gdy AI pisze Twój aplikację React, ale pomija zasady czystego kodu.
Wzorzec 1: podzielenie sekcji na cztery komponenty
Zapytaj agenta o sekcję interfejsu użytkownika – zazwyczaj umieści on otoczenie sekcji, nagłówek, wyświetlanie listy oraz poszczególne elementy w jednym komponencie. Duplikacja staje się widoczna w miarę rozwoju aplikacji, ponieważ ta sama struktura pojawia się niemal wszędzie: w blogach, sklepach internetowych, portfelach projektów, katalogach, stronach startowych typu SaaS oraz panelach sterowania.
Załóżmy stronę główną sklepu internetowego z sekcjami poświęconymi nowym, popularnym i najpopularniejszym produktom. Każda sekcja wygląda identycznie; różnią się tylko danymi. Każdą z nich można podzielić na cztery części, z których każda pełni określoną funkcję:
- otoczenie sekcji
- nagłówek sekcji
- lista kart
- jedna karta
Sekcja
Sekcja jest elementem nadrzędnym. Przyjmuje dane poprzez właściwości i przekazuje je swoim podelementom, więc zna informacje dotyczące ich składu, ale nie szczegóły stylizacji.
export function ProductSection({ title, subtitle, products }) {
return (
<section>
<SectionHeading
title={title}
subtitle={subtitle}
/>
<ProductList products={products} />
</section>
)
}
Nagłówek
Specjalny komponent nagłówkowy umożliwia przechowywanie stylizacji tytułu i podtytułu w jednym miejscu, dzięki czemu każda sekcja aplikacji wygląda spójnie, bez konieczności ponownego tworzenia struktury markup.
export function SectionHeading({ title, subtitle }) {
return (
<div>
<h2>{title}</h2>
<p>{subtitle}</p>
</div>
)
}
Lista
Lista ma jedną funkcję: iteruje po kolekcji i wyświetla kartkę dla każdego elementu, używając stałego key.
export function ProductList({ products }) {
return (
<div className="grid">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
/>
))}
</div>
)
}
Warto zauważyć, że nie ma pojęcia, jak wygląda produkt. Zna tylko sposób układania listy, co ułatwia późniejsze ponowne użycie z inną kartką.
Kartka
Kartka pokazuje tylko jeden element i nic więcej.
export function ProductCard({ product }) {
return (
<div>
<img src={product.image} />
<h3>{product.title}</h3>
<p>${product.price}</p>
</div>
)
}
Jedynym celem jest prezentacja. W rzeczywistym kodzie należałoby również nadać obrazowi znaczący atrybut alt, którego brakuje w tym minimalnym przykładzie.
Porównanie obu podejść
Jeśli zostawić wszystko na ustawieniach domyślnych, agent będzie miał tendencję do generowania czegoś w tym stylu:
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>
)
}
Technicznie nie ma z tym żadnego problemu. Koszt pojawia się później, gdy inna strona potrzebuje tego samego stylu nagłówka, projektu karty lub układu w siatce. Wtedy albo duplikuje się kod, albo wyodrębnia komponenty z bazy kodu, która już rozrosła się wokół tych duplikatów.
Dzięki podziale na cztery komponenty sama strona sprowadza się do danych i ich kompozycji:
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}
/>
</>
)
}
Struktura pliku pozostaje mała i przewidywalna:
components/
│
├── SectionHeading.tsx
├── ProductCard.tsx
├── ProductList.tsx
└── ProductSection.tsx
Każdy element składowy ma teraz jasne przeznaczenie. Ten sam nagłówek pasuje do każdego rozdziału, kolejna linia produktu to kolejny element typu ProductSection, a zupełnie nowa strona może być zbudowana z tych samych części.
Zachęta do jego stosowania
Zamiast prosić agenta o stworzenie sekcji produktu, opisz dokładnie jej strukturę: „Użyj tutaj wzorca UI składającego się z 4 komponentów: ProductSection, który zawiera SectionHeading oraz ProductList renderujący elementy ProductCard, przy czym każdy komponent pełni dokładnie jedną funkcję”. Ta jedna zdanie zmienia strukturę wyniku, a rezultat jest znacznie łatwiejszy do odczytania, ponownego użycia i konserwacji.
Wzorzec 2: przeniesienie logiki do niestandardowych hooków
Większość programistów React zna niestandardowe hooki, jednak gdy to agent zajmuje się pisaniem kodu, łatwo o ich zapomnienie. Gdy prosi się agenta o nową stronę, zazwyczaj przechowuje on stan, efekty, wywołania API oraz obsługę zdarzeń wewnątrz komponentu. Po kilku iteracjach plik osiąga 300 linii, ponieważ interfejs użytkownika i logika są ze sobą pomieszane.
Czasami dobrze jest przechowywać logikę w komponencie. Gdy ta sama logika jest potrzebna gdzie indziej lub zaczyna utrudniać zrozumienie tego, co komponent wyświetla, lepszym rozwiązaniem jest zazwyczaj wyodrębnienie hooka. Typowe przykłady to:
useDebounce()do opóźnienia wysyłki zapytania po wpisaniu tekstu w polu wyszukiwaniauseApi()do obsługi pobierania danych wraz z stanami ładowania i błędówuseLocalStorage()do przechowywania stanu w przeglądarceuseClickOutside()do zamykania rozwijanych list lub okien modalnych, gdy użytkownik kliknie gdzie indziejusePagination()do zarządzania stanem stronuseToggle()do obsługi stanów logicznych, takich jak otwarta lista boczna
Logika wyszukiwania wewnątrz komponentu
Rozważmy pole wyszukiwania produktów. Typowa wersja generowana przechowuje zarówno surowe zapytanie, jak i jego wersję po odroczeniu w stanie, wykorzystuje jeden efekt z timerem do aktualizacji wartości po odroczeniu oraz drugi efekt do pobierania danych przy każdej zmianie:
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..."
/>
)
}
To działa. Ale jeśli trzy ekrany potrzebują tego samego zachowania odroczenia, albo trzeba je skopiować trzy razy, albo później zapłacić zarówno czasem, jak i tokenami za ich wyodrębnienie. Obowiązują również dwie uwagi dotyczące odporności: zapytanie powinno zostać przekazane przez encodeURIComponent przed umieszczeniem w URL, a rzeczywiste pobieranie danych musi radzić sobie z odpowiedziami w niewłaściwej kolejności, na przykład za pomocą AbortController podczas czyszczenia efektu.
Wyodrębnianie hooka do odroczenia
Logika timera przenosi się do osobnego hooka, który przyjmuje wartość i opóźnienie oraz zwraca wartość po odroczeniu:
// 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
}
Funkcja czyszcząca usuwa wyczekujący timer za każdym razem, gdy wartość ulega zmianie przed upływem opóźnienia, co sprawia, że jest to mechanizm debounce zamiast zwykłego opóźnienia. Włączenie delay do tablicy zależności zapewnia poprawne działanie hooka, jeśli ktoś go zmieni.
Następnie komponent sprowadza się do swojego rzeczywistego celu:
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)}
/>
);
}
Renderyzacja pozostaje w komponencie, a mechanizm debounce znajduje się w hooku – każdy z nich ma jedną odpowiedzialność.
Kiedy hook jest przydatny
Nie każda wywołanie useState() zasługuje na własny hook. Dobrymi sygnałami są:
- ta sama logika występuje lub będzie występować w kilku komponentach;
- komponent staje się zbyt długi;
- funkcja łączy kilka wywołań
useState(), efektów lub obsługowników zdarzeń;
Zachęcanie do takiego podejścia
Ponieważ agenci z reguły umieszczają logikę bezpośrednio w komponentach, powiedz to wyraźnie: „Umieść zachowania wielokrotnego użycia dla tej funkcji w dedykowanych hookach, na przykład useApi(), useDebounce() lub useLocalStorage(), a komponent niech będzie odpowiedzialny wyłącznie za formatowanie.” Ta ogólna zasada obowiązuje we wszystkich czterech wzorcach: im więcej informacji architektonicznych dostarczysz, tym lepszy będzie wygenerowany kod.
Wzorzec 3: jeden komponent polimorficzny zamiast niemal identycznych komponentów
Celem komponentów wielokrotnego użycia nie jest ich większa liczba, lecz unikanie duplikatów. Agenci często tworzą takie struktury:
Button.tsx
LinkButton.tsx
IconButton.tsx
Wszystkie one mają niemal identyczny styl. Różnica polega na elemencie, który renderują: czasami rzeczywisty <button>, czasami łącze anchor, czasami link routera, a czasami kontrolka składająca się wyłącznie z ikony. Wzorzec polimorficzny zastępuje tę grupę jednym komponentem, który może być renderowany jako dowolny element wymagany przez daną sytuację.
Wersja powielona
Typowy parowany komponent wygląda w ten sposób:
// 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>
)
}
Style są powtarzane tylko dlatego, że zmienił się podstawowy element HTML.
Polimorficzny przycisk w TypeScript
wersja wpisana przyjmuje opcjonalną właściwość as, której typem jest ogólny ElementType z domyślną wartością "button". Pozostałe właściwości pochodzą od elementu lub komponentu, do którego odnosi się as, przy użyciu ComponentPropsWithoutRef; sama właściwość as jest pomijana, aby uniknąć konfliktów.
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>
)
}
Ponieważ typy właściwości są zgodne z E, użycie wartości as="a" sprawia, że href staje się ważny, a podanie obiektu Link z routera sprawia, że jego właściwość to staje się ważna – wszystko to przy jednoczesnej weryfikacji typów. Przykład jest uproszczony: variant jest akceptowany, ale jeszcze nie wykorzystywany, więc pełna wersja powinna przypisać mu nazwę klasy i połączyć ją z dowolną wartością className przekazaną jako parametr. Warto również ustawić domyślnie type="button" podczas renderowania klasycznego przycisku, aby nie doszło przypadkowo do wysłania formularza otaczającego go.
To samo komponenty obejmuje teraz wszystkie przypadki:
<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>
Przycisk „Zapisz”, „Anuluj”, link do cen, ścieżka do panelu sterowania oraz akcja usuwania mają ten sam wygląd; zmienia się jedynie renderowany element lub zachowanie.
Kiedy go używać
Prosty test: jeśli interfejs pozostaje taki sam, ale element podstawowy się zmienia, komponent jest dobrym kandydatem do zastosowania polimorfizmu. Powszechne przykłady:
- przyciski, które czasami pełnią funkcję linków
- komponenty typograficzne, które renderują
h1,h2,plubspan - karty, które czasami są elementem
article, a czasami klikalnym linkiem - kontenery układu, które renderują się jako
section,divlubmain
To nie jest indywidualna preferencja. Biblioteki komponentów takie jak Radix UI, Chakra UI i Flexy UI w dużej mierze opierają się na tej samej koncepcji, aby tworzyć elastyczne systemy projektowe.
Zachęcanie do jej stosowania
Agenci mają tendencję do tworzenia nowego komponentu dla każdej drobnej modyfikacji, więc podaj im wzorzec wraz z nazwą: „Napisz jeden polimorficzny Button, który przyjmuje właściwość as i może renderować natywny button, tag a lub element Link z React Router; obsługuj warianty i rozmiary wewnątrz tego jednego komponentu, zamiast tworzyć nowe.” W rezultacie masz mniej komponentów, mniej duplikatów oraz bardziej spójny system projektowy.
Wzorzec 4: konsolidacja powiązanych stanów za pomocą reduktora
To prawdopodobnie wzorzec, który najczęściej pomijają agenci. Ich standardem jest osobny useState() dla każdej wartości, co jest w porządku dla prostych komponentów. Gdy jednak funkcjonalność się rozwija, musisz zarządzać pięcioma lub sześcioma elementami stanu, które od siebie zależą:
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");
Gdy już w grę wchodzą paginacja, filtrowanie, sortowanie, ładowanie oraz obsługa błędów, jedno działanie użytkownika zazwyczaj musi aktualizować kilka z tych funkcji jednocześnie, co sprawia, że komponent staje się trudny do zrozumienia.
Koordynowane funkcje ustawiające
Oto jak wygląda operacja fetch przy oddzielnych stanach:
setLoading(true)
try {
const data = await fetchProducts()
setProducts(data)
setError(null)
} catch {
setError("Something went wrong")
} finally {
setLoading(false)
}
Kilka funkcji ustawiających jest wywoływanych jednocześnie w związku z jednym logicznym zdarzeniem. To grupowanie jest klasycznym sygnałem, że reducer lepiej oddałby tę logikę.
Modellowanie przejść jako działań
Z użyciem reducera opisuje się raz strukturę stanu i wymienia przejścia, przez które może on przechodzić. Składnia będzie znajoma, jeśli korzystałeś z 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 ustawia flagę ładowania, FETCH_SUCCESS przechowuje produkty i usuwa zarówno flagę ładowania, jak i informację o błędzie, natomiast FETCH_ERROR rejestruje błąd. W przypadku sukcesu zwracany jest nowy, kompletny obiekt; jeśli później dodasz do stanu pola takie jak page lub sortBy, pamiętaj, aby tam również użyć operacji ...state, w przeciwnym razie te wartości zostaną pominięte.
Następnie komponent wysyła akcje zamiast wywoływać funkcje ustawiające stan:
const [state, dispatch] = useReducer(
reducer,
initialState
)
dispatch({ type: "FETCH_START" })
dispatch({
type: "FETCH_SUCCESS",
payload: products,
})
Każda aktualizacja przechodzi teraz przez jedną funkcję. Aby zrozumieć, jak działa dana funkcjonalność, czyta się reducer zamiast szukać wywołań setState w całym komponencie, a ponieważ reducer jest funkcją czystą, jego testowanie jednostkowe jest proste.
Kiedy nadaje się reducer
Redukery nie są odpowiednie dla każdego komponentu. Są przydatne wtedy, gdy:
- kilka zmian stanu zachodzi jednocześnie;
- funkcja ta ma wiele odrębnych działań;
- przejścia pomiędzy stanami stają się trudne do śledzenia;
- funkcja ta to koszyk zakupów, wieloetapowy przewodnik, proces logowania, duży formularz lub zatłoczona karta sterująca.
Dla modalu lub przełącznika zwykła funkcja useState() pozostaje lepszym wyborem.
Zachęcanie do jej użycia
Bez wskazówek agent prawie zawsze będzie tworzył serię wywołań useState(). Poproś o strukturę, jakiej chcesz: „Zaimplementuj to za pomocą useReducer: zdefiniuj nazwane typy działań, umieść każdą zmianę stanu wewnątrz funkcji reduktora i niech komponent robi tylko renderowanie oraz wysyłanie sygnałów.” Im większa jest funkcja, tym bardziej to się opłaca.
Główne wnioski
- Agenty AI niezawodnie tworzą działający kod w React, ale z reguły używają komponentów monolitycznych, logiki wewnątrz komponentów, niemal identycznych wariantów oraz rozproszonego stanu.
- Cztery wzorce pomagają poradzić sobie z tymi standardowymi rozwiązaniami: kompozycja sekcji, niestandardowe hooki, komponenty polimorficzne oraz reduktory.
- Każdy wzorzec ma wyraźny sygnał do zastosowania; należy go użyć w momencie pojawienia się tego sygnału, a nie instynktownie.
- Nazwanie wzorca oraz elementów, których oczekujesz w swoim poleceniu, to najtańszy sposób na uzyskanie lepszej architektury od agenta.
- Generowanie kodu staje się z każdym rokiem łatwiejsze; decyzja o jego strukturze nadal należy do programisty, a znajomość tych wzorców umożliwia jej podjęcie.
Literatura pokrewna
- Trzy patterny TypeScript, które ulepszają architekturę aplikacji React — Dowiedz się, w jaki sposób patterny Repository, Observer i Builder wykorzystują system typów TypeScript do tworzenia czystszych i łatwiejszych w utrzymaniu kodów w React i Next.js.
- Naprawianie przeładowania propów w React za pomocą kompozycji i slotów — Dowiedz się, dlaczego propy w React o dużej liczbie ustawień powodują problemy z utrzymaniem, oraz jak inwersja kontroli, kompozycja i sloty pozwalają tworzyć naprawdę wielokrotnie używalne komponenty.