Skip to content
ALL WRITING
2025-09-14
8 min read
React · TypeScript

Compound components without the footguns

Config-prop components rot as requirements grow. Compound components stay clean — if you avoid three specific mistakes. This is the pattern I use in production, with the reasoning.

Every component starts simple. A Select takes options and onChange. Then a design needs an icon in one option. Then a divider. Then a search box above the list. Six months later the component takes nineteen props, four of which are functions that return JSX, and nobody remembers which combinations are legal.

Compound components solve this by making structure the API. Instead of describing the UI with props, the consumer composes it:

usagetsx
<Select value={plan} onChange={setPlan}>
  <Select.Trigger placeholder="Choose a plan" />
  <Select.List>
    <Select.Option value="starter">Starter</Select.Option>
    <Select.Option value="team" icon={<TeamIcon />}>Team</Select.Option>
  </Select.List>
</Select>

New requirements become new children, not new props. The parent owns state; the children read it through context. That's the whole pattern — and it's also where the footguns live.

Footgun 1 — context that fails silently

A Select.Option rendered outside a Select gets null context and, in the naive implementation, quietly renders nothing. The fix is a context hook that names the offender and throws immediately:

select-context.tstsx
function useSelectCtx(component: string) {
  const ctx = React.useContext(SelectCtx);
  if (!ctx) {
    throw new Error(
      `<${component}> must be rendered inside <Select>`
    );
  }
  return ctx;
}

A crash at development time beats a blank region in production. Every subcomponent pays this one-line tax.

Footgun 2 — one context, every re-render

Putting state and actions in a single context object means every keystroke in the search box re-renders every option. Split the context in two — values that change (selection, highlight) and things that don't (registration callbacks, ids) — and memoize the stable half. In a 40-option list this is the difference between "instant" and "sticky" on a mid-range phone, which is where your users actually are.

Footgun 3 — types that allow nonsense

If Select.Option's value is typed as string while the parent's onChange receives a union type, the compiler can't catch a typo'd option. Make Select generic over the value type and let the subcomponents infer it. The consumer writes no type annotations, and an option that doesn't belong is a red squiggle, not a bug report.

When not to use this

Compound components are for UI with meaningful internal structure that consumers legitimately rearrange. A button doesn't qualify. A form field with a fixed label-input-error layout doesn't either — a plain prop API is simpler and harder to misuse. Reach for this pattern when you catch yourself adding the third render-prop; not before.

Ziad Hatem · 2025-09-14