Design Patterns

4 min read
Rapid overview

React Design Patterns (Interview-Ready)

Patterns in React are mostly about composition (building features by combining small components) rather than inheritance.

Table of Contents


Compound Components

What it is: a parent component owns state and shares it with “subcomponents” via Context so you can write an expressive API like:

<Tabs defaultValue="profile">
  <Tabs.List>
    <Tabs.Trigger value="profile">Profile</Tabs.Trigger>
    <Tabs.Trigger value="billing">Billing</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="profile">...</Tabs.Panel>
  <Tabs.Panel value="billing">...</Tabs.Panel>
</Tabs>

Why it’s useful:

  • Avoids prop drilling in complex UI widgets (tabs, accordion, dropdown, menu).
  • Enables flexible composition while keeping state in one place.
  • Improves readability: the JSX structure becomes the “API”.

✅ Good example: Compound Toggle

import React from 'react';

const ToggleContext = React.createContext(null);

function useToggleContext() {
  const ctx = React.useContext(ToggleContext);
  if (!ctx) throw new Error('Toggle.* must be used within <Toggle>');
  return ctx;
}

export function Toggle({ defaultOn = false, children }) {
  const [on, setOn] = React.useState(defaultOn);
  const value = React.useMemo(() => ({ on, setOn }), [on]);
  return <ToggleContext.Provider value={value}>{children}</ToggleContext.Provider>;
}

Toggle.On = function ToggleOn({ children }) {
  const { on } = useToggleContext();
  return on ? children : null;
};

Toggle.Off = function ToggleOff({ children }) {
  const { on } = useToggleContext();
  return on ? null : children;
};

Toggle.Button = function ToggleButton(props) {
  const { on, setOn } = useToggleContext();
  return (
    <button type="button" aria-pressed={on} onClick={() => setOn((v) => !v)} {...props} />
  );
};

❌ Bad example: Prop drilling

function Toggle({ on, setOn }) {
  return (
    <div>
      <ToggleOn on={on} />
      <ToggleOff on={on} />
      <ToggleButton on={on} setOn={setOn} />
    </div>
  );
}

Best practices:

  • Prefer Context + small subcomponents over React.Children.map “magic”.
  • Fail fast: throw if a subcomponent is used outside its provider.
  • Keep the context value stable (useMemo) to reduce re-renders.
  • Provide controlled + uncontrolled support for reusable libraries (see below).
Q: When should you use compound components?

A: When multiple child components need shared state/behavior and you want an expressive, composable API (tabs, menu, accordion).


Controlled vs Uncontrolled Components

Controlled: parent owns state (value, onChange). Uncontrolled: component owns state (defaultValue) and notifies changes.

✅ Good example: controlled/uncontrolled API shape

function useControllableState({ value, defaultValue, onChange }) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const isControlled = value !== undefined;
  const current = isControlled ? value : uncontrolled;

  const set = React.useCallback(
    (next) => {
      const nextValue = typeof next === 'function' ? next(current) : next;
      if (!isControlled) setUncontrolled(nextValue);
      onChange?.(nextValue);
    },
    [current, isControlled, onChange]
  );

  return [current, set];
}
Q: Why support both?

A: Apps often start uncontrolled (simple), then move to controlled when state must be synchronized (forms, URL, server state).


Provider Pattern (Context)

Use Context to provide cross-cutting dependencies or shared UI state (theme, auth, feature flags, tabs state).

Best practices:

  • Split contexts by “change frequency” (e.g., AuthStateContext vs AuthActionsContext) to reduce re-renders.
  • Memoize provider values: useMemo(() => ({...}), [deps]).
  • Keep providers close to where they’re needed; avoid a “provider soup” at the root unless justified.

State Reducer Pattern

What it is: component manages state, but lets consumers override transitions via a reducer.

Why it’s useful:

  • Makes reusable components more flexible without exposing internals.
  • Allows apps to enforce rules (e.g., prevent toggle off in some cases).
function defaultToggleReducer(state, action) {
  switch (action.type) {
    case 'toggle':
      return { ...state, on: !state.on };
    default:
      return state;
  }
}

function useToggle({ defaultOn = false, reducer = defaultToggleReducer, onChange }) {
  const [state, dispatch] = React.useReducer(reducer, { on: defaultOn });
  const toggle = () => dispatch({ type: 'toggle' });

  React.useEffect(() => {
    onChange?.(state.on);
  }, [onChange, state.on]);

  return { on: state.on, toggle };
}
Q: When is the state reducer pattern better than onChange only?

A: When you need to customize logic (state transitions), not just observe state changes.


Render Props

What it is: pass a function as a child/prop to share state + behavior.

function Mouse({ children }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {children(pos)}
    </div>
  );
}

Trade-offs:

  • ✅ Very flexible, explicit data flow
  • ❌ Can create “JSX nesting” and re-render hot paths if not careful

Higher-Order Components (HOC)

What it is: a function that takes a component and returns an enhanced component.

const withAuth = (Component) => (props) => {
  const user = useUser();
  if (!user) return <Login />;
  return <Component {...props} user={user} />;
};

Modern guidance: prefer hooks and composition for new code, but recognize HOCs in existing codebases.


Container / Presentational Split

Keep data fetching and state in a container, and keep UI rendering in a presentational component.

function UserCardContainer({ userId }) {
  const { data, isLoading } = useUserQuery(userId);
  return <UserCard user={data} loading={isLoading} />;
}

function UserCard({ user, loading }) {
  if (loading) return <Spinner />;
  return <div>{user.name}</div>;
}

Polymorphic Components (as prop)

Used in design systems to let a component render as different HTML tags while preserving styling and behavior.

function Text({ as: As = 'span', ...props }) {
  return <As {...props} />;
}

Best practice: in TypeScript, make this type-safe (generic T extends ElementType) to preserve props of the chosen element.