Skip to main content
CodeOath
← All posts

React120 min total · 17 parts

React Fundamentals: Components, Hooks, and the Virtual DOM

Part 14 of 17 · ~4 min

Component Composition Patterns

The dashboard is now a working application, so the question changes from "how do I make this work" to "how do I keep this from calcifying". These are the shapes that hold up.

Children as composition

Our Drawer from chapter 2 is the pattern in its simplest form, and the version we did not write shows the alternative:

// Rigid: Drawer has to anticipate every use, forever
<Drawer
  title="Priya Raghunathan"
  body={<SubmissionSummary candidate={candidate} />}
  showForm
  formFields={["notes", "rating"]}
  footerButtons={["reject", "advance"]}
  showCloseButton
/>

// Composable: Drawer owns the shell, the caller owns the contents
<Drawer title="Priya Raghunathan" onClose={close}>
  <SubmissionSummary candidate={candidate} />
  <ReviewForm candidateId={candidate.id} />
</Drawer>

The first version gains a prop every time somebody needs something slightly different, and every prop becomes a branch inside Drawer that everyone else's use has to survive. The second one does not change when a new kind of drawer content is needed, because the content was never its concern.

The generalisation: when a component is about structure rather than content, take the content as children. Layouts, cards, panels, modals, page shells, list containers.

children is not the only slot available. Passing elements through named props gives you more than one hole to fill, which is what you want as soon as a component has regions:

<Drawer
  header={<CandidateHeader candidate={candidate} />}
  footer={<ReviewActions candidateId={candidate.id} />}
>
  <SubmissionSummary candidate={candidate} />
</Drawer>

There is a performance property hiding in this that is worth knowing, since it surprises people. An element passed as a child is created by the parent, before it is handed over. So when Drawer re-renders for its own reasons, children is the same element object it already had, and React can skip re-rendering that subtree — without any React.memo involved. Passing content as children rather than rendering it inside is a real optimisation, not only a readability one.

Compound components

Once a component has several regions, keeping them under one namespace makes the relationship explicit and lets them share state without the caller wiring it:

<Drawer onClose={close}>
  <Drawer.Header>{candidate.name}</Drawer.Header>
  <Drawer.Body><SubmissionSummary candidate={candidate} /></Drawer.Body>
  <Drawer.Footer>
    <button onClick={reject}>Reject</button>
    <button onClick={advance}>Advance</button>
  </Drawer.Footer>
</Drawer>

The pieces are built to be used together, and they communicate through a context that Drawer provides internally:

const DrawerContext = createContext(null);

function Drawer({ onClose, children }) {
  const value = useMemo(() => ({ onClose }), [onClose]);
  return (
    <DrawerContext.Provider value={value}>
      <aside className="drawer">{children}</aside>
    </DrawerContext.Provider>
  );
}

Drawer.Header = function DrawerHeader({ children }) {
  const { onClose } = useContext(DrawerContext);       // no prop wiring by the caller
  return (
    <header>
      <h2>{children}</h2>
      <button onClick={onClose} aria-label="Close">×</button>
    </header>
  );
};

The caller never passes onClose to the header. That is the trade being made: less wiring and a more expressive API, in exchange for a piece of shared state the caller cannot see. Worth it for a component library or a widely reused shell; usually not worth it for something used twice.

Render props, and what replaced them

Before hooks came along, getting two components to share the same stateful logic meant reaching for either a higher-order component or the render props pattern — passing a function that the component calls with its state:

<CandidateLoader candidateId={selectedId}>
  {({ detail, status }) =>
    status === "loading" ? <Spinner /> : <SubmissionSummary candidate={detail} />
  }
</CandidateLoader>

That does exactly what useCandidateDetail does in chapter 12, and it is worth comparing them directly, because the comparison is the clearest possible argument for hooks. The render-prop version has to introduce a component to own the state, which puts a layer in your tree, and consuming two of them means nesting two layers and indenting twice. The hook version is one line with no tree impact at all, and using two is two lines.

The related older shape is the higher-order component — a function that takes a component and returns a wrapped one, like withReviewer(CandidateRow). Same purpose, same problem: layers in the tree, plus prop-name collisions that only surface at runtime. You still meet HOCs constantly, and not always as legacy — React.memo is itself one.

Neither pattern is wrong today, and both still appear in libraries with good reason, especially where the shared thing genuinely needs to render something. Recognise them on sight; reach for a custom hook first.