Strona główna / Artykuły

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.

Wydawca: REACTAPP.TOP12 min czytania2819 słów

Ź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 wyszukiwania
  • useApi() do obsługi pobierania danych wraz z stanami ładowania i błędów
  • useLocalStorage() do przechowywania stanu w przeglądarce
  • useClickOutside() do zamykania rozwijanych list lub okien modalnych, gdy użytkownik kliknie gdzie indziej
  • usePagination() do zarządzania stanem stron
  • useToggle() 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ń;
  • Wywołania API, formularze, funkcje wyszukiwania, stronicowanie, mechanizmy autoryzacji oraz kod służący do przechowywania danych zaszkadzają logice renderowania;
  • chcesz przetestować tę logikę niezależnie od interfejsu użytkownika.
  • 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, p lub span
    • karty, które czasami są elementem article, a czasami klikalnym linkiem
    • kontenery układu, które renderują się jako section, div lub main

    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

  • Bounded Prompts: Jak powstrzymać asystentów AI przed tworzeniem paralelnego stacku — Dlaczego niejasne prośby o funkcje sprawiają, że asystenci programistyczni dodają duplikatowe biblioteki i otoczniki, oraz jak ograniczone polecenia z ścieżkami, niezmiennikami i sztywnymi ograniczeniami to naprawiają.
  • Wybieranie wzorców TypeScript według złożoności, którą usuwają — Przegląd klasycznych wzorców projektowych oraz technik TypeScript na poziomie typów, z jasnymi wskazówkami dotyczącymi sytuacji, gdy każdy z nich ma sens, a kiedy lepszy jest zwykły kod.