React Component Patterns

Build scalable component architectures with established design patterns.

TL;DR

  1. 01Design controlled component APIs so parents own state via value and onChange props.
  2. 02Use the Provider Pattern to share state across a subtree without prop drilling.
  3. 03Pick compound components for closed sub-component sets, render props when callers control markup.

Tips

  1. 01If your component accepts both value (controlled) and defaultValue (uncontrolled), warn in development when a consumer switches between the two modes — React's own inputs do this and it prevents subtle bugs.
  2. 02Memoize the context value with useMemo when the provider re-renders frequently — otherwise every consumer re-renders even when the value has not changed.
  3. 03Export compound sub-components as static properties of the parent so the public API stays a single cohesive import instead of several loose named exports.
  4. 04When your container grows complex, extract it into a custom hook that returns { users, loading, error } — this keeps the JSX layer thin and moves all async logic to a testable function.

Warnings

  1. 01Avoid nesting render props three levels deep or overusing Context — both create tight coupling that makes components harder to test and reuse in isolation.

Controlled Component API

A controlled component exposes a value prop and an onChange callback so the parent owns the state entirely. This is React's "single source of truth" contract — the component never holds private internal state for the value it displays.

// Controlled input component
function TextInput({ value, onChange, placeholder }) {
  return (
    <input
      type="text"
      value={value}
      placeholder={placeholder}
      onChange={(e) => onChange(e.target.value)}
    />
  );
}

// Parent owns the state
function LoginForm() {
  const [email, setEmail] = useState('');

  return (
    <TextInput
      value={email}
      onChange={setEmail}
      placeholder="Email address"
    />
  );
}
  • Fully controlled: parent passes value and onChange — no internal state for the value.
  • Uncontrolled fallback: omit value and use a ref via useRef for one-time reads (e.g., file inputs).
  • Validation and transformation happen in the parent's onChange handler before state is updated.

Provider Pattern

The Provider Pattern uses React Context to broadcast shared state — such as theme, locale, or authenticated user — to any descendant without threading props through every intermediate component. Unlike compound components, the consumers can be anywhere in the subtree and do not need to be specific child types.

const ThemeContext = createContext({ mode: 'light', toggle: () => {} });

function ThemeProvider({ children }) {
  const [mode, setMode] = useState('light');

  const toggle = () =>
    setMode((m) => (m === 'light' ? 'dark' : 'light'));

  return (
    <ThemeContext.Provider value={{ mode, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

// Custom hook keeps consumers clean
function useTheme() {
  return useContext(ThemeContext);
}

// Deep consumer — no prop drilling needed
function ThemeToggleButton() {
  const { mode, toggle } = useTheme();
  return <button onClick={toggle}>Switch to {mode === 'light' ? 'dark' : 'light'}</button>;
}

// App wires it together
<ThemeProvider>
  <App />   {/* ThemeToggleButton anywhere inside has access */}
</ThemeProvider>
  • Pair every Provider with a custom hook (useTheme) so consumers get a clean call-site API.
  • Split contexts by update frequency — a slowly-changing locale and a rapidly-changing modal state belong in separate contexts to avoid unnecessary re-renders.
  • Contrasted with compound components: Provider Pattern is for app-wide or subtree-wide concerns; compound components are for self-contained component suites.

Typed Compound Components

  • At an advanced level, the interesting problem is not wiring a parent and child through Context — it is making the wiring type-safe and resilient to consumers reordering, omitting, or wrapping children. Start by typing the context value precisely and refusing a default object that could silently mask a missing provider.
    type SelectContextValue<T> = {
      value: T;
      onChange: (next: T) => void;
    };
    
    function createSelectContext<T>() {
      return createContext<SelectContextValue<T> | null>(null);
    }
    
  • A generic context factory lets Select<string> and Select<number> both get full type inference on value and onChange, instead of widening everything to any the way a single shared context would.
    const SelectContext = createSelectContext<string>();
    
    function useSelectContext() {
      const ctx = useContext(SelectContext);
      if (!ctx) throw new Error('Select.Option must be used inside <Select>');
      return ctx; // narrowed to SelectContextValue<string>, never null past this line
    }
    
  • Context-based implicit sharing is convenient but invisible — a consumer cannot tell from JSX alone which children are required versus optional. React.Children.map with cloneElement is the explicit alternative: the parent injects props directly into each child it recognizes, so the contract is visible in the child's own prop types rather than hidden behind a context read.
    function Select({ children, value, onChange }: SelectProps) {
      const items = Children.map(children, (child) => {
        if (!isValidElement<OptionProps>(child)) return child;
        return cloneElement(child, {
          selected: child.props.value === value,
          onSelect: () => onChange(child.props.value),
        });
      });
      return <div role="listbox">{items}</div>;
    }
    
  • Trade-off: cloneElement breaks if a consumer wraps an option in their own <div> or fragment — the clone only reaches direct children, so nesting silently drops the injected props. Context has no such restriction; any descendant, however deeply wrapped, can call useSelectContext(). Prefer Context for any compound API you expect consumers to wrap or rearrange; reserve cloneElement for sealed, internal component sets where you control every usage site.
  • Build a flexible API by making the parent tolerant of missing, reordered, or extra children — derive an index from Children.toArray instead of relying on prop-based IDs, so consumers can omit an item conditionally without renumbering the rest.
    const orderedIds = Children.toArray(children)
      .filter(isValidElement<OptionProps>)
      .map((child) => child.props.value);
    // Safe even if a caller does {showCanada && <Select.Option value="ca" />}
    
  • Export a discriminated union of sub-component prop types from the same module as the parent, so Select.Option usage outside <Select> is rejected by both the runtime guard and the type checker before the code ever ships.

Generic Render Prop APIs

  • The basic mechanic — passing a function as a prop — is table stakes; the advanced concern is making that function's signature generic so the same component works across unrelated data shapes without consumers casting or duplicating the component.
    type FetcherProps<T> = {
      url: string;
      children: (state: { data: T | null; error: Error | null; loading: boolean }) => React.ReactNode;
    };
    
    function Fetcher<T>({ url, children }: FetcherProps<T>) {
      const [state, setState] = useState<{ data: T | null; error: Error | null; loading: boolean }>(
        { data: null, error: null, loading: true }
      );
    
      useEffect(() => {
        let cancelled = false;
        fetch(url)
          .then((r) => r.json())
          .then((data: T) => !cancelled && setState({ data, error: null, loading: false }))
          .catch((error) => !cancelled && setState({ data: null, error, loading: false }));
        return () => { cancelled = true; };
      }, [url]);
    
      return <>{children(state)}</>;
    }
    
    // T is inferred at the call site — no casting needed
    <Fetcher<User[]> url="/api/users">
      {({ data, loading }) => (loading ? <Spinner /> : <UserList users={data ?? []} />)}
    </Fetcher>
    
  • The modern hooks-based alternative to this exact component is a generic useFetcher<T>(url) hook that returns the same { data, error, loading } shape — for a component-tree consumer that already has hooks available, the hook is simpler because it adds no nesting and no extra component to the React tree.
    function useFetcher<T>(url: string) {
      const [state, setState] = useState<{ data: T | null; error: Error | null; loading: boolean }>(
        { data: null, error: null, loading: true }
      );
      useEffect(() => {
        let cancelled = false;
        fetch(url).then((r) => r.json()).then((data: T) => !cancelled && setState({ data, error: null, loading: false }));
        return () => { cancelled = true; };
      }, [url]);
      return state;
    }
    
  • Render props are still the better choice in three cases the hook cannot cover: class component consumers (which cannot call hooks at all), library code that must support both hook and non-hook consumers from one component, and JSX-driven APIs (like a <Form> validation summary) where the producer needs to inject markup conditionally rather than just hand back values for the caller to render itself.
  • Watch for the classic render-prop performance trap: defining the render function inline causes React to see a new function reference every parent render, which defeats React.memo on the render-prop component itself (though it does not affect the children the function returns).
    // Bad inside a frequently re-rendering parent: new closure every render
    <MemoizedFetcher<User[]> url={url}>
      {(state) => <UserList {...state} />}
    </MemoizedFetcher>
    
    // Better: stable reference via useCallback when the producer is memoized
    const renderUsers = useCallback(
      (state: FetchState<User[]>) => <UserList {...state} />,
      []
    );
    <MemoizedFetcher<User[]> url={url}>{renderUsers}</MemoizedFetcher>
    
  • This only matters when the render-prop component itself is wrapped in React.memo — for an unmemoized producer, the inline closure costs nothing extra since the producer re-renders with its parent regardless.

Container/Presentational Pattern

The Container/Presentational pattern separates data-fetching logic from pure rendering. The container handles side effects, state, and API calls; the presentational component is a pure function of props with no side effects, making it trivially unit-testable and reusable across different data sources.

// Container: owns data fetching and state
function UserListContainer() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/users')
      .then((r) => r.json())
      .then((data) => { setUsers(data); setLoading(false); });
  }, []);

  if (loading) return <p>Loading...</p>;
  return <UserList users={users} />;
}

// Presentational: pure UI, no side effects
function UserList({ users }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
  • Container components are responsible for what data is shown; presentational components control how it looks.
  • Presentational components are easy to test with hardcoded prop data — no mocking required.
  • In a hooks world, the container is often a custom hook (useUserList) rather than a wrapper component, but the separation of concerns stays the same.
  • Useful for Storybook: presentational components can be developed and documented with mock props independent of any API.

FAQ