Skip to content

6.4.4 — Component Patterns & the Ecosystem

A dropdown closes when you click outside it, using the pattern from Chapter 6.3.1:

tsx
useEffect(() => {
  const onDocClick = () => setOpen(false);
  document.addEventListener('click', onDocClick);
  return () => document.removeEventListener('click', onDocClick);
}, []);

And a React button inside the dropdown that calls e.stopPropagation() does not stop it. The menu closes anyway.

The reason is that React does not attach your onClick to the button at all. Understanding where it is attached explains this bug, explains why portals behave the way they do, and is the right place to start on the parts of React that are conventions rather than mechanisms.

1. Synthetic events, and where handlers really live

React does not call button.addEventListener('click', yourHandler). It attaches one listener per event type to the root container — the DOM node you passed to createRoot — and uses the delegation pattern from Chapter 6.3.1 to work out which component's handler to call.

The object your handler receives is a SyntheticEvent: a wrapper with the same interface as the native event, normalised across browsers. e.nativeEvent gets you the real one.

Now the opening bug resolves. The click happens on the button, bubbles up the real DOM to the React root, and React's single listener runs your handler there. Your stopPropagation() stops propagation within React's synthetic system — but the native event has already travelled past every ancestor between the button and the root, and it continues up to document, where your useEffect listener is waiting.

React's synthetic propagation and the DOM's native propagation are two different journeys, and the synthetic one happens later.

Three ways out, best first:

tsx
// (1) Best: do not stop anything. Ask where the click landed.
useEffect(() => {
  const onDocClick = (e: MouseEvent) => {
    if (menuRef.current?.contains(e.target as Node)) return;
    setOpen(false);
  };
  document.addEventListener('click', onDocClick);
  return () => document.removeEventListener('click', onDocClick);
}, []);

// (2) Listen in the capture phase so you see it before React's root listener.
document.addEventListener('click', onDocClick, { capture: true });

// (3) Reach through to the native event.
onClick={(e) => e.nativeEvent.stopImmediatePropagation()}

Option (1) is the same advice Chapter 6.3.1 gave, for the same reason: not breaking propagation avoids breaking things you cannot see.

A piece of history worth knowing because it explains an old class of bug. Before React 17, the root listener was attached to document itself. That meant two React versions on one page, or React inside a non-React application, fought over the same delegation point — a stopPropagation in the outer application could silently kill every event in the inner React tree. React 17 moved attachment to the root container, which made those mixed setups work. React 17 also removed event pooling, the old behaviour where the event object was reused and its fields nulled after the handler returned; the e.persist() calls you may see in older code exist for that and are no longer needed.

The naming conventions, and the two that are not what they look like

Handlers are camelCase props: onClick, onChange, onSubmit, onDoubleClick (not ondblclick), onMouseEnter. Every one has a capture variant: onClickCapture.

onChange is the one that is genuinely different from the DOM. In the DOM, change fires when a text field is committed — on blur (Chapter 6.3.1). React's onChange fires on every keystroke, behaving like the native input event. React did this deliberately, because a controlled input needs the value on every character, and it is the reason onChange feels "wrong" to anyone coming from plain HTML.

value versus defaultValue is the controlled/uncontrolled switch:

tsx
<input value={name} onChange={e => setName(e.target.value)} />   {/* controlled */}
<input defaultValue={name} ref={nameRef} />                       {/* uncontrolled */}

A controlled input's value comes from React state, so React is the single source of truth: you can validate on each keystroke, format as the user types, and disable a submit button live. The cost is a render per keystroke.

An uncontrolled input keeps its own state in the DOM and you read it when you need it. Cheaper, simpler, and the right choice for a large form where per-keystroke re-rendering is wasted work — which is exactly what form libraries do internally.

The bug you will hit once: passing value with no onChange makes the field read-only and logs a warning; passing value={undefined} initially and a string later switches the input from uncontrolled to controlled mid-life and logs a different warning. Give controlled inputs an initial ''.

2. The composition patterns that survived

React has had several waves of pattern fashion. Three survived, and each is a design pattern from Chapter 9.4 with a React spelling.

Children and slots

The simplest and most underrated:

tsx
function Panel({ header, children, footer }: PanelProps) {
  return (
    <section className="panel">
      <header>{header}</header>
      <div className="panel-body">{children}</div>
      {footer && <footer>{footer}</footer>}
    </section>
  );
}

Passing elements as props gives the caller full control of the content while Panel owns the structure. It also gives the render-skipping benefit from Chapter 6.4.1 — an element created in the parent keeps its identity when Panel re-renders.

Compound components

When several parts must share state but the caller must control the markup:

tsx
const TabsContext = createContext<TabsApi | null>(null);

function Tabs({ defaultTab, children }: TabsProps) {
  const [active, setActive] = useState(defaultTab);
  const api = useMemo(() => ({ active, setActive }), [active]);
  return <TabsContext.Provider value={api}>{children}</TabsContext.Provider>;
}

function Tab({ id, children }: { id: string; children: ReactNode }) {
  const { active, setActive } = useTabs();          // throws with a clear message if outside
  return (
    <button role="tab" aria-selected={active === id} onClick={() => setActive(id)}>
      {children}
    </button>
  );
}

Tabs.Tab = Tab;
Tabs.Panel = TabPanel;

The caller writes <Tabs><Tabs.Tab id="a">…</Tabs.Tab><Tabs.Panel id="a">…</Tabs.Panel></Tabs> and can put anything between the parts — a divider, a heading, a wrapper — without Tabs needing a prop for it. The shared state moves invisibly through context. This is the standard way accessible component libraries are built, and it is the Composite idea from Chapter 9.4.11 with a React shape.

Custom hooks, which replaced two older patterns

Higher-order components — a function taking a component and returning a wrapped one — were the original way to share behaviour. This is the Decorator pattern (Chapter 9.4.8), and it works, but it produces deep wrapper stacks in the tree, loses the display name unless you set it, collides on prop names between two decorators, and makes types awkward.

Render props — passing a function as a child so the child receives values — is the Strategy pattern (Chapter 9.4.12). It fixed the naming collisions and produced the "callback pyramid" instead.

Custom hooks replaced both, because they share logic without adding anything to the tree:

tsx
const { data, isLoading } = useOrders(customerId);   // no wrapper, no nesting

You will still meet higher-order components in older codebases and in a few places where wrapping the render is genuinely the point. Knowing that they are Decorator, and that hooks replaced them because the wrapper itself was the cost, is the useful framing.

3. What classes are still for

Every lifecycle method has a hook equivalent, and the mapping is worth having once:

ClassHook
constructoruseState initialiser
componentDidMountuseEffect(fn, [])
componentDidUpdateuseEffect(fn, [deps])
componentWillUnmountthe cleanup returned from useEffect
getDerivedStateFromPropscompute during render, or a key
shouldComponentUpdateReact.memo
componentDidCatchno equivalent

That last row is why classes still exist. An error boundary must be a class component.

tsx
class ErrorBoundary extends React.Component<Props, State> {
  state = { hasError: false };

  // (1) Render a fallback on the next render.
  static getDerivedStateFromError() {
    return { hasError: true };
  }

  // (2) The side-effect half: report it.
  componentDidCatch(error: Error, info: React.ErrorInfo) {
    reportToMonitoring(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

Line (1) is the pure half, allowed to run during the render phase. Line (2) is the side-effecting half, which runs at commit — the split follows exactly the phase rules from Chapter 6.4.1.

What an error boundary does not catch, and this list is the whole point of the question when it comes up:

  • Errors in event handlers. A handler runs outside rendering, so use a normal try/catch.
  • Errors inside setTimeout, promises, or any async callback.
  • Errors thrown in the boundary itself.
  • Server-rendering errors (Chapter 6.5.1 handles those separately).

Place boundaries around independently failing regions — a dashboard widget, a route, an embedded third-party component — so one broken chart does not blank the page. One boundary at the root means any error blanks everything, which is barely better than the crash.

4. Portals

Chapter 6.2.5 established the problem: a modal inside a container with overflow: hidden, a transform, or a trapped stacking context cannot escape it, no matter what z-index it carries.

tsx
function Modal({ children, onClose }: ModalProps) {
  return createPortal(                        // (1)
    <div className="backdrop" onClick={onClose}>
      <div className="dialog" role="dialog" aria-modal="true">{children}</div>
    </div>,
    document.body,                            // (2)
  );
}

Line (2) is the point: the DOM nodes are appended to <body>, outside every wrapper, so nothing can clip or trap them.

The behaviour that surprises everybody: events still bubble through the React tree, not the DOM tree. A click inside the portal reaches an onClick on the component that rendered the portal, even though there is no DOM ancestor relationship at all. That follows directly from section 1 — React's synthetic propagation walks its own tree, and the portal is a child in the React tree regardless of where its DOM landed. It is usually exactly what you want, and it is unsettling the first time.

Context also crosses the portal normally, for the same reason.

The <dialog> element is now often the better answer for modals specifically. It renders in the browser's top layer, above every stacking context by definition, and showModal() gives you focus trapping, Escape to close, and inert background content for free. Chapter 6.8.2 covers it.

5. Suspense and lazy loading

tsx
const RichTextEditor = lazy(() => import('./RichTextEditor'));   // (1)

<Suspense fallback={<EditorSkeleton />}>                          {/* (2) */}
  <RichTextEditor doc={doc} />
</Suspense>

Line (1) uses the dynamic import() from Chapter 6.3.2, so the editor becomes a separate chunk the bundler emits and the browser only downloads when it is first rendered.

Line (2) is the boundary that shows something while that happens. What Suspense actually is: a boundary that catches a component signalling "I am not ready yet" and shows a fallback until it is. Lazy loading is one thing that can signal it; a data-fetching library that supports Suspense is another; and in a server-rendering setup it also marks a region that can stream in separately (Chapter 6.5.1).

Practical placement matters. A boundary at the route level gives one skeleton per page, which is usually right. Boundaries around every small component produce a page of flickering placeholders. And a fallback whose size differs from the real content causes layout shift, which is measured directly by the Core Web Vitals in Chapter 6.7 — a skeleton should be the same shape as the thing it stands in for.

6. The data layer that deletes most of your effects

Chapter 6.4.3 argued that most global state is really server state. TanStack Query is the widely used answer, and it is worth understanding what it owns rather than its API surface.

tsx
function OrderList({ customerId }: { customerId: string }) {
  const { data, isPending, isError, error } = useQuery({
    queryKey: ['orders', customerId],          // (1) identity of this data
    queryFn: ({ signal }) => fetchOrders(customerId, signal),   // (2)
    staleTime: 30_000,                          // (3)
  });

  if (isPending) return <Skeleton />;
  if (isError) return <ErrorPanel error={error} />;
  return <Table rows={data} />;
}

Line (1) is the whole model: data is identified by a key, and the cache is keyed by it. Two components asking for ['orders', 'c-42'] at the same time produce one network request and both get the result. Change the key and it is a different query, so changing customerId fetches the new data and keeps the old cached.

Line (2) receives an AbortController signal, so a query that is no longer needed is cancelled — the race from Chapter 6.4.2, handled for you.

Line (3) staleTime is the one knob that most changes behaviour. Within it, the cached data is considered fresh and no request is made. After it, the data is stale but still shown, and a refetch happens in the background — the stale-while-revalidate model from Chapter 5.6.2, applied in the client. The user sees data instantly and it silently updates.

What you stop writing, and this is the honest list: loading and error flags, deduplication of identical concurrent requests, cancellation of superseded requests, refetch on window focus and on reconnect, retry with backoff, cache invalidation after a mutation, and pagination bookkeeping. That is several hundred lines per application, all of it subtly wrong the first time.

Mutations close the loop:

tsx
const { mutate } = useMutation({
  mutationFn: cancelOrder,
  onSuccess: () => {
    // (1) Mark everything under this key stale; visible queries refetch.
    queryClient.invalidateQueries({ queryKey: ['orders'] });
  },
});

Line (1) is the mental model shift. You do not update a store after a write — you declare that the server's data changed, and anything showing it refetches. There is no chance of the cache and the server disagreeing because you forgot to update one of four places.

Optimistic updates apply the change locally first and roll back on failure, which is the client-side half of the idempotency reasoning in Chapter 10.4.

The rest of the ecosystem, and what to learn in what order

A router with typed routes and search parameters is the second piece worth adopting, because it makes the Chapter 6.4.3 point enforceable: filters and pagination live in the URL, typed and validated, rather than in a store. TanStack Router does this and adds per-route loaders so data starts fetching as navigation begins rather than after the component mounts.

A meta-framework — Next.js, TanStack Start, Remix — adds the server: routing, server rendering, data loading and bundling in one. That is Chapter 6.5.

A sensible order to learn them: the platform (Chapters 6.1–6.3) → React itself → a server-state library, because it removes the most code → a router, because URL state is the next biggest win → a meta-framework, only when you need a server. Adopting a meta-framework before understanding what it is doing on your behalf is how teams end up unable to debug their own rendering.

What the interviewer will push on

"Why doesn't stopPropagation in a React handler stop a document listener?" Because React attaches one listener at the root container and runs your handler there, by which time the native event has already bubbled past every ancestor. Synthetic and native propagation are separate journeys. Then give the fix — contains(e.target) — rather than the workaround.

"Controlled or uncontrolled inputs?" Controlled when React must know the value on every keystroke — live validation, formatting, dependent fields. Uncontrolled for large forms where per-keystroke rendering is waste, which is what form libraries do internally. Mention the uncontrolled-to-controlled warning, because it is the bug people actually hit.

"Why must an error boundary be a class?" There is no hook equivalent for componentDidCatch. Then list what it does not catch — event handlers, async callbacks, itself, server rendering — because that is the part that decides whether your error handling actually works.

"What are portals for, and what surprises people?" Escaping overflow: hidden and trapped stacking contexts. The surprise is that events bubble through the React tree, not the DOM tree, which follows from root-level delegation. Volunteer that <dialog> now handles modals better because it uses the browser's top layer and gives focus trapping for free.

"Higher-order components versus hooks?" Higher-order components are the Decorator pattern; they added wrapper layers, collided on prop names, and complicated types. Hooks share logic without adding anything to the tree. Naming the pattern shows you see React as an instance of general design rather than a separate universe.

"Why use a data-fetching library instead of useEffect?" Because you would otherwise hand-write deduplication, cancellation, retries, staleness, refetch-on-focus and invalidation — and get the request race wrong. The model is a keyed cache with stale-while-revalidate, and mutations invalidate rather than update, so the cache cannot silently disagree with the server.

One thing to volunteer: explain why React's onChange fires on every keystroke while the DOM's change fires on commit — React needs the value per character for controlled inputs. It is a small deliberate divergence, it confuses everyone once, and knowing it was a decision rather than an accident shows you have read about the library rather than only used it.

Recall

  • React attaches one listener per event type to the root container and works out which handler to call. So a synthetic stopPropagation runs after the native event has already bubbled past — which is why it cannot stop a document listener. Use contains(e.target) instead.
  • React 17 moved attachment from document to the root (fixing mixed React versions) and removed event pooling, making e.persist() obsolete.
  • React's onChange fires on every keystroke, unlike the DOM's change. value + onChange is controlled; defaultValue + a ref is uncontrolled. Start controlled inputs at '' or you get the uncontrolled-to-controlled warning.
  • The surviving composition patterns: children/slots (also keeps element identity, so subtrees skip re-rendering), compound components sharing state through context, and custom hooks, which replaced higher-order components (Decorator, Chapter 9.4.8) and render props (Strategy, Chapter 9.4.12) because the wrapper itself was the cost.
  • An error boundary must be a classgetDerivedStateFromError for the fallback, componentDidCatch for reporting. It does not catch event handlers, async callbacks, itself, or server rendering. Place boundaries per independently failing region.
  • Portals escape overflow: hidden and trapped stacking contexts, and events still bubble through the React tree, not the DOM tree. For modals, <dialog> with showModal() is usually better — top layer, focus trap, Escape, all free.
  • Suspense is a boundary for "not ready yet": lazy chunks, suspending data, streamed server regions. Place it per route, and make the fallback the same shape as the content or you create layout shift.
  • A server-state library owns the keyed cache: one request for duplicate keys, cancellation via signal, staleTime giving stale-while-revalidate, and mutations that invalidate rather than update, so the cache cannot silently disagree with the server.
  • Learning order: platform → React → server-state library → typed router (URL state) → meta-framework last.

Self-test: Where is your onClick actually attached, and what does that break? · When is an uncontrolled input the better choice? · Name three things an error boundary will not catch · Why do portal clicks reach the parent component's handler? · What exactly do you stop writing when you adopt a query cache?

Next: 6.5.1 puts a server back in the picture — the four places HTML can be generated, what hydration actually costs, and why the industry spent five years trying to send less JavaScript for the same page.