Field Guide · Vol. 5
Patterns → Production

React Design Patterns That Earn Their Keep.

Patterns aren't decoration. Each one exists because not using it creates real, recurring pain: duplicated logic, props drilled through ten layers, components that know too much, components that know too little. This guide pairs every pattern with the bug it prevents.

01 · The Foundation

Composition via children

Build flexible components by letting consumers pass JSX in, not by configuring through props.

The problem without it: You build a Card with title, subtitle, image props. A week later, you need an icon. Then a badge. Then two actions. Each new use case adds another prop. The component becomes a god-object full of conditionals.
✗ Configuration via props
function Card({ title, subtitle, image, icon, badge, action1, action2 }) {
  return (
    <div className="card">
      {image && <img src={image} />}
      {icon && <Icon name={icon} />}
      <h3>{title}</h3>
      {subtitle && <p>{subtitle}</p>}
      {badge && <Badge>{badge}</Badge>}
    </div>
  );
}
✓ Composition
function Card({ children }) {
  return <div className="card">{children}</div>;
}

Card.Header = ({ children }) => <div className="card-header">{children}</div>;
Card.Body   = ({ children }) => <div className="card-body">{children}</div>;

// Usage — caller composes whatever they need:
<Card>
  <Card.Header><Badge>New</Badge><h3>Product</h3></Card.Header>
  <Card.Body>Best one</Card.Body>
</Card>

Pros

  • Infinitely flexible — no prop explosion
  • Easier to read at the call site
  • Built into React, zero overhead

Cons

  • Less rigid contract
  • Harder to enforce specific structure

Use when: a component is a container, layout, modal, card, panel — anything that wraps content.

02 · Separation of Concerns

Container / Presentational

One component fetches and computes. Another renders. Each does one job.

The problem without it: Your UserList fetches users, manages loading state, handles errors, and renders the UI. To test rendering, you mock fetch. To reuse the UI elsewhere, you can't.
✓ Split
// Presentational — pure, easy to test
function UserListView({ users, loading, error }) {
  if (loading) return <Spinner />;
  if (error) return <Error msg={error.message} />;
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

// Container — knows about data
function UserListContainer() {
  const { users, loading, error } = useUsers();
  return <UserListView users={users} loading={loading} error={error} />;
}

Pros

  • Easy to test rendering with fixtures
  • Designers can work on view in isolation
  • Multiple containers can share one view

Cons

  • Two files per feature
  • Hooks made this less critical
03 · The Modern Default

Custom Hooks

Extract stateful logic into reusable functions. Markup stays in components; behavior lives in hooks.

The problem without it: Three components need to fetch data with loading/error states. You copy-paste 15 lines into each. A bug? Three places to fix.
✓ One hook, many consumers
function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    fetch(url).then(r => r.json())
      .then(d => { if (!cancelled) setData(d); })
      .catch(e => { if (!cancelled) setError(e); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [url]);

  return { data, loading, error };
}

// Every consumer is one line:
function UserList() {
  const { data, loading, error } = useFetch('/api/users');
}

Pros

  • True logic reuse
  • Independently testable
  • Composable — hooks can use hooks
  • Community standard since 2019

Cons

  • Rules of hooks — no conditional calls
  • Dependency arrays easy to get wrong
  • Stale closure bugs are #1 React gotcha
04 · Implicit State Sharing

Compound Components

A group of components that work together, sharing state through context — like select and option.

The problem without it: You build a Tabs component. To customize one tab's rendering, you add a renderTab prop. To customize the panel, another. The API balloons.
✓ Compound API
const TabsContext = createContext();

function Tabs({ defaultValue, children }) {
  const [active, setActive] = useState(defaultValue);
  return (
    <TabsContext.Provider value={{ active, setActive }}>
      {children}
    </TabsContext.Provider>
  );
}

Tabs.Tab = ({ value, children }) => {
  const { active, setActive } = useContext(TabsContext);
  return <button onClick={() => setActive(value)}>{children}</button>;
};

Tabs.Panel = ({ value, children }) => {
  const { active } = useContext(TabsContext);
  return active === value ? <div>{children}</div> : null;
};

// Usage reads naturally:
<Tabs defaultValue="a">
  <Tabs.Tab value="a">First</Tabs.Tab>
  <Tabs.Panel value="a"><FirstPanel /></Tabs.Panel>
</Tabs>

Pros

  • Declarative API — looks like HTML
  • Used by Radix, Headless UI, Reach UI
  • Each child styled independently

Cons

  • More boilerplate to set up
  • Hard to enforce required structure
  • TypeScript types get fancy

Use when: building a design-system component with multiple related parts that share state — Tabs, Accordion, Menu, Select, Dialog, Toast.

05 · Behavior Sharing (legacy)

Render Props

A component takes a function as a child and calls it with internal state. Mostly replaced by hooks.

✓ Render prop pattern
function MouseTracker({ children }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const h = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', h);
    return () => window.removeEventListener('mousemove', h);
  }, []);
  return children(pos);  // children is a function!
}

// Usage:
<MouseTracker>
  {(pos) => <p>Mouse at {pos.x}, {pos.y}</p>}
</MouseTracker>

Pros

  • Maximum flexibility
  • Works in class components
  • Used by Formik, react-router v5

Cons

  • "Wrapper hell" when composing
  • Extra render overhead
  • Hooks do it better in 90% of cases
06 · Wrapper (legacy)

Higher-Order Components

A function that takes a component and returns a new one with added behavior.

✓ HOC pattern
function withAuth(Component) {
  return function AuthenticatedComponent(props) {
    const user = useUserFromStore();
    if (!user) return <LoginRedirect />;
    return <Component {...props} user={user} />;
  };
}

const ProfilePage = withAuth(Profile);

Pros

  • Works with class components
  • Standardized cross-cutting concerns
  • Composable

Cons

  • Wrapper hell in devtools
  • Prop name collisions
  • TypeScript inference is painful
07 · Escape Prop Drilling

Provider / Context

Make values available to any descendant without passing them through every layer.

The problem without it: App has the current theme. SidebarItem (4 layers deep) needs it. So you pass theme through 4 components that don't care about it.
✓ Context
const ThemeContext = createContext();

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('dark');
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

// Deep in the tree, no props needed:
function SidebarItem() {
  const { theme } = useContext(ThemeContext);
  return <div className={theme}>...</div>;
}

Pros

  • Eliminates prop drilling for global state
  • Built into React
  • Easy to add consumers without touching the chain

Cons

  • Every consumer re-renders on value change
  • Forgot to memoize value? Every render triggers downstream re-renders
  • Don't use for everything
08 · State Ownership

Controlled vs Uncontrolled

Who owns the input's value — the component itself or its parent?

Uncontrolled — input owns its state
function SearchInput() {
  const ref = useRef();
  return <input ref={ref} defaultValue="" />;
}
Controlled — parent owns the state
function SearchInput({ value, onChange }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

The "best of both" — controllable

function Input({ value, defaultValue, onChange }) {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(defaultValue ?? '');
  const currentValue = isControlled ? value : internal;

  const handleChange = (e) => {
    if (!isControlled) setInternal(e.target.value);
    onChange?.(e.target.value);
  };

  return <input value={currentValue} onChange={handleChange} />;
}

Controlled — pros

  • Parent has full visibility
  • Easy to derive other state
  • Required for debounced search, master/detail

Controlled — cons

  • Re-renders parent on every keystroke
  • More boilerplate
  • Forgot onChange? Read-only with warning

Choosing the right pattern

Most "patterns" are answers to a specific recurring pain. Memorize the pain, not the pattern.

If you're feeling…Reach for…
"This component has too many props"Composition via children
"I keep copy-pasting this useEffect"Custom hook
"Designers can't iterate without breaking data"Container / Presentational
"Building Tabs / Accordion / Menu"Compound components
"I'm drilling props through layers"Context (or zustand)
"Parent needs to read or set this value"Controlled component
"Working in legacy class components"HOC or render props

Staff-level perspective: the strongest patterns combine. A modern design system component is usually a compound component that uses context internally, exposes a custom hook for advanced consumers, and supports both controlled and uncontrolled modes.

React design patterns field guide · Frontend Field Guides

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

Keep reading