Four Design Principles for Building React with AI

Four principles for guiding AI toward a consistent frontend codebase: colocation, clear logic boundaries, UI conventions, and useful context.

Published
February 20, 2026
日本語で読む

One page in the repository uses a container pattern, the next uses Suspense, and a third fetches its data in useEffect. Hand the frontend to an agent without deciding anything first, and that is what you get back.

Speed is not the problem. The missing piece is an agreement about how the code should be written. Four guidelines cover most of it:

  1. Keep related code together
  2. Choose clear boundaries for logic
  3. Define UI implementation conventions
  4. Give the agent access to the right context

Why frontend work is difficult for AI

Frontend code contains a large number of implicit conditions. One component may need to account for mobile, tablet, and desktop layouts; hover, focus, and active states; absolute, fixed, and sticky positioning; and loading, error, empty, and success states. The combinations grow quickly.

There is also no single architecture that fits every React application. Directory structure, state management, and data-fetching strategy depend on the product, team, and scale.

Without explicit context, an agent makes local decisions. Each of the choices from the opening works in isolation, and together they leave the codebase without a common shape. Humans still need to define the boundaries within which the agent works.

Colocation means placing files that change together physically close to one another. Instead of filling global components/ and hooks/ directories with feature-specific code, organize that code around the feature.

src/
  components/
    UserProfile.tsx
    UserPosts.tsx
  hooks/
    useUser.ts
    usePosts.ts

This looks simple at first, but it becomes difficult to see which Hook belongs to which component as the directories grow. A request to change the profile feature may touch components/, hooks/, utils/, and types/, scattering both the agent’s context and the reviewer’s attention.

A feature-oriented layout narrows the working area.

features/
  greeting/
    index.tsx
    use-greeting.ts
    constants.ts
  user-profile/
    index.tsx
    use-profile.ts

Now an instruction can point to features/greeting/, and the resulting diff is easier to understand.

Borrow the useful part of Bulletproof React

Bulletproof React is a useful reference for feature-oriented architecture.

GitHub - alan2207/bulletproof-react: 🛡️ ⚛️ A simple, scalable, and powerful architecture for building production ready React applications.🛡️ ⚛️ A simple, scalable, and powerful architecture for building production ready React applications. - alan2207/bulletproof-reactgithub.com

A project does not need to adopt every rule. The colocation principle alone provides much of the value.

My default is:

  • Keep feature code under features/.
  • Promote a component or Hook to shared code only after reuse is real.
  • Make module boundaries explicit, but avoid barrels when they add more indirection than value.

The last point depends on the team. A carefully enforced public API can protect feature boundaries. In a smaller codebase, direct imports may be easier to trace. The important part is to choose one policy deliberately and document it.

Start with larger components

When an agent generates a feature, I do not ask it to split everything into small components immediately. As discussed in Better to Write Bad Code, it is often more practical to begin with a larger component and split it when a real boundary appears.

Of five speculative abstractions, perhaps one will be reused. The other four remain as extra files and indirection. A working component gives us evidence: a section is reused, a piece of logic becomes difficult to read, or a concern deserves its own test. Agents are generally good at a concrete follow-up instruction such as “extract this section into a component.”

2. Choose clear boundaries for logic

A React component can be thought of as UI = f(state): it receives state and produces UI. The question is where data acquisition and decisions should live.

Container pattern

One option is to collect data and decisions in a parent and pass presentation-ready props to children.

function UserPage({ id }: { id: string }) {
  const user = useUser(id);
  const posts = usePosts(id);
  const canEdit = user.role === "admin";

  return <UserProfile user={user} posts={posts} canEdit={canEdit} />;
}

function UserProfile({ user, posts, canEdit }: Props) {
  return (
    <div>
      <h1>{user.name}</h1>
      {canEdit && <EditButton />}
      <PostList posts={posts} />
    </div>
  );
}

The location of the policy is obvious. To change the editing rule, look at UserPage; UserProfile only needs the final boolean. This structure is also easy to describe to an agent.

Treat forms as a distinct boundary

Forms are tightly coupled to field registration, validation, dirty state, and error display. Pulling every detail into the page can create a large prop surface that fights the form library.

I usually let the form own its validation and field state while the parent decides what submission means.

function UserEditPage({ id }: { id: string }) {
  const updateUser = useUpdateUser(id);
  return <UserForm onSubmit={updateUser} />;
}

function UserForm({ onSubmit }: { onSubmit: (data: UserInput) => void }) {
  const form = useForm<UserInput>({
    resolver: zodResolver(userSchema),
  });

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <input {...form.register("name")} />
      <p>{form.formState.errors.name?.message}</p>
      <button type="submit" disabled={form.formState.isSubmitting}>
        Save
      </button>
    </form>
  );
}

Async Server Components

In a framework that supports React Server Components, an async component can fetch the data it needs. Suspense boundaries then define which areas may reveal independently.

async function UserPosts({ id }: { id: string }) {
  const posts = await fetchPosts(id);
  return <PostList posts={posts} />;
}

function UserPage({ id }: { id: string }) {
  return (
    <>
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile id={id} />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <UserPosts id={id} />
      </Suspense>
    </>
  );
}

This allows the profile to appear before a slower post list. The tradeoff is that data access is distributed among the components that need it. Error Boundaries must also be placed intentionally so one failed area does not replace the entire page.

Neither approach is universally better. A container makes policy easy to locate; async components make incremental rendering natural. Choose according to the product and write down where each pattern belongs. A rule such as the following gives both people and agents a shared default:

## Data fetching

- Fetch page data in the page component and pass it down as props.
- Do not fetch data in child components with useEffect.
- Forms may own field state and validation; receive onSubmit as a prop.

3. Define UI implementation conventions

The same visual result can be produced with utility classes, CSS Modules, inline styles, or several layout techniques. Without constraints, an agent may choose a different method in every file.

Define the widths you will verify

Do not attempt to design a unique layout for every possible width. Define representative sizes, for example 375px and 1280px, and make them part of the acceptance criteria. Intermediate widths must remain readable and operable, but they do not all need a bespoke composition.

With Tailwind, a mobile-first rule makes the direction of overrides predictable.

function Card({ title, description }: Props) {
  return (
    <div className="flex flex-col gap-2 p-4 md:flex-row md:gap-6 md:p-8">
      <h2 className="text-lg md:text-2xl">{title}</h2>
      <p className="text-sm text-gray-600 md:text-base">{description}</p>
    </div>
  );
}

Agents can produce the broad layout, but text wrapping, overlap, and overflow around breakpoints still need browser verification.

Build from trusted UI primitives

A library such as shadcn/ui gives an agent a known vocabulary of Card, Button, Dialog, and other primitives. The same idea works with an internal design system: humans establish the quality of the primitives, and the agent composes them.

This reduces the surface on which the agent invents new CSS and keeps variants, spacing, and accessibility behavior more consistent.

Let parents own layout

A reusable component should generally own its internal appearance, not its surrounding margin or page position. Accept a className and let the parent decide layout in context.

import { cn } from "@/lib/utils";

function UserCard({ user, className }: Props) {
  return (
    <div className={cn("rounded-lg border p-4", className)}>
      <h2 className="text-lg font-bold">{user.name}</h2>
      <p className="text-sm text-gray-600">{user.bio}</p>
    </div>
  );
}

The card owns its border and internal padding. Its parent owns margins, grid placement, and surrounding space. That boundary prevents later callers from fighting hard-coded layout with negative margins or !important.

4. Give the agent the right context

An agent can read code, but that does not mean it can see the rendered page or the source design. “The spacing looks wrong” is weak context unless the agent can inspect what is actually on screen.

Browser context

Chrome DevTools MCP lets an agent inspect a live page’s DOM, computed styles, console, network activity, and performance traces. This makes a useful loop possible: change the code, inspect the result, and correct regressions.

For flows involving authentication and repeated browser interaction, Playwright MCP is another option. It works from structured accessibility snapshots and can maintain state during exploratory automation. Scenarios that must remain repeatable should ultimately be preserved as ordinary Playwright tests.

Design context

Figma MCP Server can provide layout, component, variable, and other design context from Figma. It is not a one-click conversion to production-quality code. It gives the agent better source material, while implementation judgment remains part of the work.

This becomes especially useful when the codebase already contains mapped design-system components. The instruction can reference both the design and the primitives the implementation is expected to use.

Codebase context

Large repositories create another problem: reading too many irrelevant files consumes context and can teach the agent an obsolete pattern. Semantic code-search tools such as Serena can help an agent retrieve the definitions and references relevant to a change.

Regardless of the tool, the goal is the same: provide enough evidence to make the right change without flooding the context with unrelated code.

Closing note

Back to the repository with three data-fetching styles in it. That happens because the decision was never written down anywhere, not because the agent is careless. Give it a frame and it moves quickly inside it.

A human team can hold consistency through tacit knowledge for a while. An agent cannot read that atmosphere, so the rules have to be visible in the repository. Defining and evolving those boundaries is still our job.

References