Component Composition Patterns in React That Actually Scale

Component Composition Patterns in React That Actually Scale
Brandon Perfetti

Technical PM + Software Engineer

Topics:React component compositionCompound componentsRender props
Tech:ReactJavaScriptTypeScript

Most tutorials show one-off components: a Button, a Modal, a Tab set. They rarely show the patterns you need once a codebase grows — multiple teams, accessibility requirements, and dozens of product permutations. In practice, the difference between a component that ships and one that scales is its composition model: how state is colocated, how children are composed, and how the public API evolves. This article walks the practical patterns senior engineers use: compound components, render props, slots, renderless controllers (hooks-first), and the API rules that keep components maintainable. I’ll be opinionated where it matters and provide concrete, implementable guidance.

1) The design principle: colocate intent, separate rendering

Start with two simple rules I use on every component: colocate state close to intent, and separate rendering from control logic. Colocate state where the mutations are expressed: if a Tab tracks which tab is active, the Tabs component should own that state by default. Separating rendering means either exposing primitives (compound components) or exposing a controller hook (renderless pattern) so teams can style and render freely.

Why this matters: colocated intent reduces accidental prop drilling and makes uncontrolled vs controlled APIs straightforward. Separating logic from UI avoids lock-in: the same logic can power a headless component, a design-system component, or a product-specific wrapper.

  • Default to internal state but expose controlled props: value/onChange + defaultValue.
  • Encapsulate state mutations in a small API surface (toggle, select, open, close).
  • Split pure logic (hooks) from markup so other renderers (SSR, native) can reuse logic.

2) Compound components — the practical implementation

Compound components let consumers compose a parent and children with implicit wiring (Tabs -> TabList -> Tab -> TabPanel). The trick senior devs use: hide the context and only expose stable, typed props to children, plus a registration model when children mount. Don’t rely on implicit child order parsing (i.e., reading children by index) — use explicit IDs and registration.

Implementation notes: The parent mounts a Provider with a state object that contains the active key, a register/unregister API, and accessor helpers. Children call register({ id, element }) in useEffect and receive callbacks and props via context. Keep the context minimal and memoized so unrelated consumers don’t re-render.

  • Use a Context value shaped like: {stateRef, actionsRef, register}, where refs hold stable objects to reduce re-renders.
  • Register children on mount: register(id, {role, getDomNode}) to track dynamic children and order.
  • Expose helper props functions: getTabProps(id) returns role, aria-controls, onClick; parent controls state.

3) Render props vs hooks-first: when to pick which

Render props (or function-as-child) were popular because they gave full control of rendering while sharing behavior. Hooks largely replace many use-cases, but render props still have value when you want to supply a render-time bag of props and state directly in JSX. My rule: prefer hooks (renderless controller) for reusability; use render props for small, explicit UIs or backward compatibility.

If you offer both, implement hooks as the source of truth and implement the render prop API as a thin wrapper around the hook. That keeps a single implementation for logic and reduces maintenance cost.

  • Primary export: useXController(...) that returns getProps helpers and state.
  • Secondary export: <X>{controller => <div {...controller.getRootProps()} />}</X> which internally calls useXController.
  • Avoid exposing DOM refs in render props; provide getXProps for proper ref forwarding.

4) Slots and named composition: the API senior teams prefer

Slots (named children) are the pragmatic way to compose complex patterns: instead of parsing children structure, accept a 'slots' prop: <Dialog slots={{Header: MyHeader, Footer: MyFooter}} /> or accept props like startAdornment/endAdornment. This is explicit, easier to type, and more robust as components evolve.

Takeaways for implementation: accept components or render functions as slot values. Provide default slots and merge user-provided ones. In TypeScript, type slots as Partial<Record<SlotNames, ComponentType>> and use generics for extensibility.

  • Expose slots prop with defaults: const slots = {...defaultSlots, ...props.slots}.
  • Support both Component and (props)=>JSX for flexibility.
  • Prefer named slots over fragile children indexing; document slot contract (required vs optional).

5) Renderless components: hooks-first and controller patterns

Renderless components move logic into hooks and expose a controller object. Example: useSelect returns {isOpen, highlightedIndex, getToggleProps, getItemProps}. The rendering component calls those helpers to wire DOM attributes and events. This pattern scales because design teams can implement visuals while product teams reuse accessibility and behavior.

Important implementation details: keep helper props stable (useCallback), return refs via getProps rather than exposing DOM nodes directly, and avoid storing heavy objects in state. Write small, focused helper functions that compose well (getItemProps should be cheap and idempotent).

  • Return small helpers: getRootProps, getToggleProps, getItemProps instead of full JSX.
  • Keep helpers stable so they can be destructured without re-creating event handlers each render.
  • Document how to combine controller helpers with existing event handlers (e.g., call user onClick before internal).

6) API ergonomics: controlled vs uncontrolled, refs, and versioning

Good APIs avoid surprises. Support both controlled and uncontrolled usage with a simple pattern: accept value + onChange and also defaultValue. Internally detect control: isControlled = value !== undefined. Only use internal state when uncontrolled. Provide forwardRef so parent code can integrate with focus management, and use useImperativeHandle sparingly to expose an explicit imperative API (open, close, focus).

For maintenance, plan versioning: keep backward-compatible prop aliases for a release cycle and emit runtime warnings for deprecated behaviors. Tests should include both controlled and uncontrolled flows.

  • Implement useControlled(value, defaultValue) hook to centralize logic and warnings.
  • Use forwardRef + useImperativeHandle to expose minimal imperative API: focus(), scrollIntoView().
  • Expose clear deprecation paths and runtime warnings (only in dev) for prop changes.

7) Performance patterns for real apps

Composition patterns can cause surprising re-renders. Concrete fixes I use: split context into many small contexts, memoize provider values, and expose selectors when needed. If you have deeply nested consumers, prefer subscription-based patterns (register + subscription callbacks) to avoid broadcasting large state objects. For high-frequency updates (typing, mouse position) move those to local state or refs.

Also ensure your components are SSR-friendly: avoid reading window/document during render and provide isomorphic defaults. Use React.memo for leaf components and prefer stable helper props so memoization isn’t invalidated by new functions each render.

  • Avoid sending large objects through Context; use primitive selectors or multiple contexts per concern.
  • Memoize callbacks with useCallback and values with useMemo; return stable helper functions from hooks.
  • Use registration + subscription pattern for dynamic child lists instead of broadcasting arrays that change identity.

Conclusion

Component composition is where maintenance cost and developer velocity collide. Pick patterns that make intent explicit (slots, named APIs), keep control logic centralized and reusable (renderless hooks), and design the public API for controlled/uncontrolled use while exposing minimal imperative handles. These patterns reduce subtle bugs, make accessibility consistent, and let design and product teams iterate independently. I prefer hooks-first implementations with compound convenience wrappers — it gives the best trade-off between ergonomics and long-term scalability.

Action Checklist

  1. Inventory three UI components in your codebase and classify them: compound-ready, renderless-friendly, or slot-based.
  2. Refactor one component into a hooks-first controller (extract logic into useXController) and provide a compound wrapper for convenience.
  3. Add a useControlled helper and forwardRef/useImperativeHandle to the refactor to support both controlled and uncontrolled consumers.
  4. Introduce registration for any component that renders dynamic children (Tabs, Menu) to avoid brittle children-order assumptions.
  5. Add focused tests covering controlled vs uncontrolled modes and ensure components are stable under memoization.