Kodokon kodokon.com

Expert patterns: compound components, inversion of control

Design flexible component APIs with compound components, the state reducer, and the right choice between render props and hooks.

10 min · 3 questions

Open this lesson in Kodokon

A <Tabs items={...} labels={...} icons={...} /> component always ends up buried under configuration props. The compound components pattern flips the approach: several components collaborate and share implicit state through a context, while the user keeps control of the JSX composition (order, nesting, interleaved elements). It is the model of <select> and <option> in HTML, and the one used by libraries like Radix or Headless UI.

JSX
const TabsContext = createContext(null);

function Tabs({ defaultTab, children }) {
  const [active, setActive] = useState(defaultTab);
  const value = useMemo(
    () => ({ active, setActive }),
    [active]
  );
  return (
    <TabsContext.Provider value={value}>
      {children}
    </TabsContext.Provider>
  );
}
The parent exposes shared state through a memoized context.

Each subcomponent reads the context and needs no wiring props at all: the user composes freely, and the pattern guarantees consistency. Note the context value wrapped in useMemo: without it, every render of the parent would create a new object and force a render of all consumers.

JSX
function Tab({ id, children }) {
  const ctx = useContext(TabsContext);
  return (
    <button
      aria-selected={ctx.active === id}
      onClick={() => ctx.setActive(id)}
    >
      {children}
    </button>
  );
}

// <Tabs defaultTab="a">
//   <Tab id="a">Profile</Tab>
//   <Tab id="b">Settings</Tab>
// </Tabs>
No wiring props: the context makes the connection.

Second pattern: inversion of control, whose most refined form is the state reducer popularized by Kent C. Dodds in Downshift. Rather than adding a boolean prop for every new need, the hook exposes its state transitions: the caller provides a reducer that receives each action and can let it through, modify it, or cancel it. The library keeps its default behavior; fine-grained control belongs to the consumer.

JSX
function defaultReducer(state, action) {
  if (action.type === 'toggle') {
    return { on: !state.on };
  }
  return state;
}

function useToggle({ reducer = defaultReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, {
    on: false,
  });
  const toggle = () => dispatch({ type: 'toggle' });
  return { on: state.on, toggle };
}
The caller can intercept every state transition.

Finally, the eternal render props versus hooks debate. Hooks have won for sharing logic: no fake hierarchy in the tree, no pyramid of wrappers, natural composition of multiple sources. But the render prop keeps a legitimate territory: when the variation concerns the rendering itself. A virtualized list that asks you how to draw each row, a headless component that delegates all its JSX: there, a render function passed as a prop remains the most direct tool, because a hook cannot decide where to insert JSX in the caller's tree.

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

// For the logic alone, a hook is enough:
// const pos = useMousePosition();
A render prop to delegate rendering, a hook for the logic.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Which mechanism lets compound components share their state without explicit props?
    • A shared module variable
    • A React context provided by the parent component
    • Duplicating the state in each child
    • Refs passed down via cloneElement
  2. What exactly does the state reducer pattern invert?
    • The direction of data flow between parent and child
    • Control over state transitions, handed to the caller
    • The execution order of hooks
    • Responsibility for rendering the fallback
  3. In which case does a render prop remain preferable to a hook?
    • When you want to share logic between components
    • When the caller must provide the JSX to insert into the component's tree
    • When you need to memoize an expensive computation
    • When the component has no state