Home / Articles

Steering AI Coding Agents with Four Named React Architecture Patterns

Learn four React patterns (section composition, custom hooks, polymorphic components, reducers) and how naming them in prompts gives you cleaner AI-generated code.

Publisher: REACTAPP.TOP12 min read2819 words

This article is published in English.

Sources: How I Get AI to Write Cleaner React Code with 4 Architecture Patterns

AI coding agents such as Claude or OpenCode can produce a working React feature in a handful of prompts, and the code usually does what you asked. The weak spot is structure: generated components often run to hundreds of lines, mixing markup, data, effects and handlers in one file, and asking the agent to "make it modular" tends to produce page-specific fragments rather than genuinely reusable pieces. This article covers four patterns that fix that structure and, just as importantly, shows how to name them in your prompts so the agent builds the architecture you intend rather than choosing one for you.

Why working code is not the same as good architecture

A common clean-code guideline is to keep a React component to roughly 100 lines, with around 200 as a practical ceiling. It is a heuristic rather than a law, but files of 500 lines or more are exactly where AI output drifts when left alone.

The obvious response is to ask the agent to split things up. It will usually oblige by breaking the UI into several smaller components inside a folder dedicated to that one page. Those pieces are smaller, but they are still tied to a single feature and are rarely usable anywhere else. That is decomposition, not modularity.

For a small project this barely matters. For software that has to evolve for years, it means every change touches code that was never designed to be reused or extended. The answer is not to stop using AI. It is to know the design patterns that make a React codebase scale, and to direct the agent with them. Code generation is becoming cheap; the judgement about how to structure it is what you still bring. If you want a broader look at how AI output tends to skip clean-code principles, see when AI writes your React app but skips clean code.

Pattern 1: split sections into four components

Ask an agent for a UI section and it will typically put the wrapper, heading, list rendering and individual items into one component. The duplication becomes obvious as the app grows, because the same structure recurs almost everywhere: blogs, online stores, portfolios, directories, SaaS landing pages and dashboards.

Picture an e-commerce home page with sections for new, trending and hot products. Every section looks identical; only the data differs. Each one can be broken into four parts with one job apiece:

  • a section wrapper
  • a section heading
  • a list of cards
  • a single card

The section

The section is the parent. It accepts data through props and hands it to its children, so it knows about composition but nothing about styling details.

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

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

The heading

A dedicated heading component keeps title and subtitle styling in one place, so every section in the app looks consistent without anyone re-creating the markup.

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

The list

The list has a single responsibility: iterate over the collection and render a card for each item, with a stable key.

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

Notice that it has no idea what a product looks like. It only knows how to lay out a list, which is what makes it easy to reuse with a different card later.

The card

The card displays one item and nothing else.

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

Presentation is its only concern. In real code you would also give the image a meaningful alt attribute, which the minimal example leaves out.

Comparing the two approaches

Left to its defaults, an agent tends to generate something like this:

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>
  )
}

Nothing is technically wrong with it. The cost appears later, when another page needs the same heading style, card design or grid. At that point you either duplicate markup or extract components from a codebase that has already grown around the duplication.

With the four-component split, the page itself reduces to data and composition:

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}
      />
    </>
  )
}

The file layout stays small and predictable:

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

Each building block now has an obvious home. The same heading works in any section, another product row is one more ProductSection, and an entirely new page can be assembled from the same parts.

Prompting for it

Instead of asking the agent to build a product section, spell out the structure: "Use the 4 Component UI Pattern here: a ProductSection that composes a SectionHeading and a ProductList, which renders ProductCard items, with each component doing exactly one job." That single sentence changes the shape of what comes back, and the result is far easier to read, reuse and maintain.

Pattern 2: move logic into custom hooks

Most React developers know custom hooks, yet when an agent is doing the typing they are easy to forget. Asked for a new screen, an agent usually keeps state, effects, API calls and event handlers inside the component. A few iterations later the file has passed 300 lines because UI and logic are interleaved.

Keeping logic in the component is sometimes fine. Once the same logic is needed elsewhere, or it starts to obscure what the component renders, extracting a hook is usually the better move. Typical candidates include:

  • useDebounce() to delay search input before sending a request
  • useApi() to handle fetching along with loading and error states
  • useLocalStorage() to persist state in the browser
  • useClickOutside() to close dropdowns or modals when the user clicks elsewhere
  • usePagination() to manage page state
  • useToggle() for boolean state such as an open sidebar

Search logic inside the component

Consider a product search box. A typical generated version keeps both the raw query and a debounced copy in state, uses one effect with a timer to update the debounced value, and a second effect to fetch whenever it changes:

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..."
    />
  )
}

This works. But if three screens need the same debounce behavior, you either paste it three times or pay later, in both time and tokens, to extract it. Two small robustness notes also apply: the query should be passed through encodeURIComponent before being placed in a URL, and a real fetch needs to handle out-of-order responses, for example with an AbortController in the effect cleanup.

Extracting a debounce hook

The timer logic moves into its own hook that takes a value and a delay and returns the debounced value:

// 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
}

The cleanup function clears the pending timer whenever the value changes before the delay elapses, which is what makes it a debounce rather than a delay. Including delay in the dependency array keeps the hook correct if a caller changes it.

The component then shrinks to its actual purpose:

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)}
    />
  );
}

Rendering stays in the component and the debouncing lives in the hook, each with one responsibility.

When a hook is worth it

Not every useState() call deserves its own hook. Good signals are:

  • the same logic appears, or will appear, in several components;
  • the component is getting long;
  • the feature combines several useState() calls, effects or event handlers;
  • API calls, forms, search, pagination, authentication or storage code are cluttering the render logic;
  • you want to test the logic independently of the UI.

Prompting for it

Because agents default to keeping logic inline, say so explicitly: "Put reusable behavior for this feature into custom hooks, for example useApi(), useDebounce() or useLocalStorage(), and leave the component responsible only for markup." The general rule holds across all four patterns: the more architectural context you provide, the better the generated code.

Pattern 3: one polymorphic component instead of near-duplicates

The goal of reusable components is not to have more of them; it is to avoid duplicates. Agents frequently produce families like this:

Button.tsx
LinkButton.tsx
IconButton.tsx

These share almost all their styling. What differs is the element they render: sometimes a real <button>, sometimes an anchor, sometimes a router link, sometimes an icon-only control. The polymorphic pattern replaces the family with one component that can render as whichever element the situation needs.

The duplicated version

A typical generated pair looks like this:

// 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>
  )
}

The styles are repeated only because the underlying HTML element changed.

A polymorphic Button in TypeScript

The typed version accepts an optional as prop, whose type is a generic ElementType defaulting to "button". Its remaining props are derived from whatever element or component as refers to, using ComponentPropsWithoutRef, with as itself omitted to avoid a clash.

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>
  )
}

Because the prop types follow E, passing as="a" makes href valid, and passing a router Link makes its to prop valid, all with type checking. The example is trimmed down: variant is accepted but not yet applied, so a complete version would map it to a class name and merge any className passed in. It is also worth defaulting type="button" when rendering a native button, so it does not accidentally submit a surrounding form.

The same component now covers every case:

<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>

Save, Cancel, a pricing link, a dashboard route and a delete action all share one design; only the rendered element or behavior changes.

When to reach for it

A simple test: if the UI stays the same but the underlying element changes, the component is a good polymorphic candidate. Common examples:

  • buttons that sometimes act as links
  • typography components that render h1, h2, p or span
  • cards that are sometimes an article and sometimes a clickable link
  • layout containers that render as section, div or main

This is not an idiosyncratic preference. Component libraries such as Radix UI, Chakra UI and Flexy UI rely heavily on the same idea to build flexible design systems.

Prompting for it

Agents tend to create a new component for every slight variation, so give them the pattern by name: "Write one polymorphic Button that takes an as prop and can render a native button, an a tag or a React Router Link; handle variants and sizes inside that single component rather than creating new ones." You end up with fewer components, less duplication and a more coherent design system.

This is probably the pattern agents overlook most. Their default is a separate useState() for every value, which is fine for simple components. As a feature grows, though, you end up juggling five or six pieces of state that depend on each other:

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");

Once pagination, filtering, sorting, loading and error handling are all in play, one user action typically has to update several of these at once, and the component becomes hard to follow.

Coordinated setters

Here is what a fetch looks like with separate state:

setLoading(true)

try {
  const data = await fetchProducts()

  setProducts(data)
  setError(null)
} catch {
  setError("Something went wrong")
} finally {
  setLoading(false)
}

Several setters fire together for a single logical event. That clustering is the classic hint that a reducer would express the logic better.

Modelling transitions as actions

With a reducer, you describe the state shape once and list the transitions it can go through. The syntax will look familiar if you have used 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 sets the loading flag, FETCH_SUCCESS stores the products and clears both loading and error, and FETCH_ERROR records the error. The success case returns a complete new object; if you later add fields such as page or sortBy to the state, remember to spread ...state there too, or those values will be dropped.

The component then dispatches actions instead of calling setters:

const [state, dispatch] = useReducer(
  reducer,
  initialState
)

dispatch({ type: "FETCH_START" })

dispatch({
  type: "FETCH_SUCCESS",
  payload: products,
})

Every update now flows through one function. To understand how the feature behaves, you read the reducer instead of hunting for setState calls across the component, and because the reducer is a pure function it is straightforward to unit test.

When a reducer fits

Reducers are not for every component. They pay off when:

  • several pieces of state change together;
  • the feature has many distinct actions;
  • state transitions are becoming hard to trace;
  • the feature is a cart, a multi-step wizard, a sign-in flow, a large form or a busy dashboard.

For a modal or a toggle, plain useState() remains the better choice.

Prompting for it

Without guidance an agent will almost always produce a stack of useState() calls. Ask for the structure you want: "Implement this with useReducer: define named action types, put every state change inside the reducer function, and let the component do nothing but render and dispatch." The larger the feature, the more this pays off.

Key takeaways

  • AI agents reliably produce working React code but default to monolithic components, inline logic, near-duplicate variants and scattered state.
  • Four patterns address those defaults: section composition, custom hooks, polymorphic components and reducers.
  • Each pattern has a clear trigger; apply it when the trigger appears, not by reflex.
  • Naming the pattern and the pieces you expect in your prompt is the cheapest way to get better architecture from an agent.
  • Generating code is getting easier every year; deciding how it should be structured is still the developer's job, and knowing these patterns is what lets you do it.