gap·map

React patterns

[ junior depth ]

Controlled vs uncontrolled components

Every interactive component answers one question: does it own its state, or does its parent?

<input defaultValue="Ada" />                            {/* uncontrolled: input owns the text */}
<input value={name} onChange={e => setName(e.target.value)} /> {/* controlled: YOUR state owns it */}

Uncontrolled: defaultValue seeds the field once, then the DOM keeps the truth. You read it later, on submit, via a ref. Controlled: value pins the field to your state. On every keystroke React re-renders and paints value back over the DOM, which is why a controlled input without onChange is frozen: the browser edit happens and is immediately overwritten by unchanged state.

A common wrong answer is "value sets the initial text." That describes defaultValue. value is a live claim of ownership, renewed every render.

The idea extends past inputs. Any component can be uncontrolled (keeps its own useState) or controlled (behavior fully determined by props like isActive/onShow). "Making it controlled" means moving the state up to whoever needs to coordinate it; the parent decides, the component displays and reports.

When to use each: uncontrolled for fire-and-forget forms, where you save wiring and read on submit. Controlled the moment anything must react to the value while it changes: validation as you type, disabling a button, filtering a list, syncing two fields.

What error boundaries catch

An unhandled error thrown during render unmounts the whole React tree and leaves a blank page. An error boundary is a component that catches render-time errors from anything below it and swaps in a fallback UI instead:

<ErrorBoundary fallback={<WidgetError />}>
  <StockTicker />
</ErrorBoundary>

Two placement instincts: one boundary near the route level so no single page can blank the app, and one around each risky island (third-party widgets, embeds, complex leaf features). The unit of thinking is "where would an error message make sense to a user?" Around a chat's message list, yes; around every avatar, no.

Know the reach even at this level: boundaries catch errors thrown while React renders children. An error inside an onClick or a fetch callback is not render-time, so the boundary won't see it (the middle notes cover what to do instead).

How React portals work

createPortal(<Tooltip />, document.body)

A portal renders children into a different DOM node while the component stays exactly where it is in the React tree. You need this because modals, tooltips, and dropdowns get clipped by overflow: hidden ancestors and trapped by stacking contexts, and a transformed ancestor hijacks position: fixed entirely (mechanics in css-layout). Portaling to <body> sidesteps the whole family of traps.

The part people miss: only the DOM placement moves. Props, context, and even event bubbling still follow the React tree.

Composition over configuration

The default smell of a growing component: hasHeader, headerText, onHeaderClick, hasCloseIcon, footerButtons... Every new use case adds a prop, and the component becomes a config parser.

The React answer is children: accept JSX instead of describing it.

<Card title="Stats" showChart chartType="bar" showLegend />   {/* configuration */}
<Card><h2>Stats</h2><BarChart legend /></Card>                {/* composition */}

A component with 20 boolean props wanted to be a layout with slots. How far you can push this (multiple slots, compound components, headless) is the middle and senior story.

[ middle depth ]

Supporting controlled and uncontrolled in one component

Library-grade components offer both contracts: value + onChange for consumers who need to coordinate, defaultValue + internal state for those who don't. The implementation is a single idiom; learn it cold:

function useControllableState({ value, defaultValue, onChange }) {
  const [internal, setInternal] = useState(defaultValue);
  const isControlled = value !== undefined;   // the consumer's choice, read per mount
  const current = isControlled ? value : internal;
  const set = (next) => {
    if (!isControlled) setInternal(next);
    onChange?.(next);                          // fire in BOTH modes
  };
  return [current, set];
}

Two rules fall out of it. The mode is fixed at mount: a component must not drift between controlled and uncontrolled. The classic accident is async data: value={user.name} while user is still {} renders value={undefined} (uncontrolled), then flips to controlled when data lands, which triggers React's "changing an uncontrolled input to be controlled" warning. Fix: value={user.name ?? ""}. Never sync the prop into state with an effect: that creates two sources of truth that fight. The fork above reads the prop directly.

Resetting an uncontrolled component from outside (say, "clear form" with fresh defaultValues)? Don't reach inside; remount it with a new key. The mechanism lives in react-rendering. Here it is the standard tool for "parent wants a reset without wanting ownership."

What error boundaries catch and what they miss

Boundaries hook React's render pipeline (getDerivedStateFromError + componentDidCatch, still class-only, which is why everyone ships react-error-boundary). So the reach is mechanical:

  • Caught: errors during render, in lifecycle methods, in constructors, from children at any depth.
  • Not caught: event handlers, async callbacks (setTimeout, fetch, promise chains), SSR, and errors thrown by the boundary component itself.

The misconception that causes real incidents: wrapping a widget in a boundary and assuming its onClick throws and failed fetches are covered. They run on the normal JS stack after render. The boundary never sees them; the error stays uncaught.

Getting them there is explicit: catch, then rethrow into render.

const { showBoundary } = useErrorBoundary();          // react-error-boundary
onClick={async () => { try { await save(); } catch (e) { showBoundary(e); } }}
// no library: setState(() => { throw e })  — a state update whose updater throws during render

Recovery is part of the API: the fallback receives resetErrorBoundary, onReset runs your retry logic, and resetKeys={[route]} auto-clears the boundary when inputs change (the classic case being "navigating away should dismiss the error"). onError is your reporting hook (error + component stack).

How portals split the DOM tree from the React tree

A portal splits the component's existence: DOM placement moves to the target node; everything React stays put. Concretely, a click inside portaled content bubbles to ancestor React onClick handlers even though the DOM node sits under <body>, and the content still reads Context from its React position. This surprises people in both directions: "why did my overlay click trigger the row's handler?" (React bubbling) and "surely the theme provider won't reach it" (it does).

What the portal does not buy you is behavior. Once your modal's DOM escapes to <body>, nothing native manages it, so the component owns the full dialog bill: move focus in on open, trap Tab inside, restore focus to the trigger on close, close on Escape and backdrop, role="dialog" + aria-modal + aria-labelledby. (Native <dialog>.showModal() covers much of this; the senior notes weigh that trade-off.)

Compound components: Context is the wiring

When subcomponents must coordinate (tabs, selects, accordions), expose the set instead of a config object:

const TabsCtx = createContext(null);
function Tabs({ value, defaultValue, onValueChange, children }) {
  const [active, setActive] = useControllableState({ value, defaultValue, onChange: onValueChange });
  return <TabsCtx.Provider value={{ active, setActive }}>{children}</TabsCtx.Provider>;
}
Tabs.Tab = ({ id, children }) => {
  const { active, setActive } = useContext(TabsCtx);
  return <button aria-selected={active === id} onClick={() => setActive(id)}>{children}</button>;
};

Consumers write <Tabs><nav><Tabs.Tab id="a">…</Tabs.Tab></nav>…</Tabs> and can wrap, reorder, and nest freely; every subcomponent finds the state through Context, with no prop drilling. The older Children.map + cloneElement variant injects props into direct children only. One wrapper <div> breaks it, and the shallow merge silently clobbers same-named props. Treat it as legacy; Context is the pattern. (Why the provider re-renders consumers and how to slice it: react-state-management.)

[ senior depth ]

How headless components split behavior from presentation

The architecture behind Radix, Headless UI, and TanStack: a component is two products, behavior (state machine, keyboard handling, ARIA wiring) and presentation (markup, styles). Headless design ships the first and lets consumers own the second. A headless Select knows open/close state, typeahead, arrow-key navigation, aria-expanded/aria-activedescendant, focus return, and renders nothing opinionated.

The senior detail is who owns which ARIA: the headless core must own everything correctness-critical (roles, state attributes, focus order), because consumers will get it wrong; consumers own labels and visuals. If your design system's a11y depends on every product team remembering aria-modal, you've shipped the wrong layer.

Delivery mechanism is a real choice. A hook (useSelect returning prop-getters) maximizes freedom, at the price that every consumer reassembles the component. Headless components (Radix-style compound set with unstyled defaults) keep structure and let you attach behavior to consumer markup via asChild: clone the child, merge props and refs, require it to spread props onto its DOM node. asChild is also why behaviors stack: a tooltip trigger and a dialog trigger can compose onto one button. Compare the string as prop: simpler, but typing polymorphic props is painful and it only swaps the tag; it can't adopt an existing component's behavior.

Component API design: the inversion-of-control ladder

Every reusable component sits on a ladder, each rung handing the consumer more control and more responsibility:

config props → slots (children, footer={<... />}) → compound components → render props → headless hook.

The judgment call is audience. An internal admin table used by one team is well served by config props; a compound headless API there is ceremony. A design-system primitive used forty ways needs the higher rungs. Moving up the ladder too late looks like boolean-prop explosion; too early looks like every consumer writing 30 lines to render a button.

Interviewers probe the render props vs hooks distinction for precision. Hooks won for sharing logic (fewer indirections, no tree noise). Render props remain the only option when the owner must render around consumer JSX: a virtualizer that positions each row, a drag layer, any "I own the subtree, you fill the holes per item with data I compute" API:

<VirtualList items={rows}>{(row, style) => <Row style={style} data={row} />}</VirtualList>

A hook can hand you data; it cannot wrap your markup, which is why "hooks made render props obsolete" is false.

Also weigh native <dialog>/popover + top layer against portal-based overlays: the platform now gives focus trapping, Escape, and above-everything rendering for free (css-layout covers the mechanics). A portal implementation buys animation and styling control at the cost of owning the whole a11y bill yourself. Where the platform already covers the requirement, taking the portal rung means paying that bill for nothing.

Error strategy at app scale

One root boundary converts any leaf error into "the entire app is now a sad face." Design tiers instead, each with a fallback that makes sense at that altitude:

  • App shell. Last resort; full-page error with reload.
  • Route/page. Page-level fallback with navigation still alive; resetKeys={[pathname]} so navigating away recovers.
  • Feature island. Widgets, panels, third-party embeds: the card apologizes, the page doesn't care. This is also your containment for code you don't own.

Wire onError into your reporting pipeline with the component stack; boundaries are where client crash telemetry comes from. Design the recovery UX per tier: retry (resetErrorBoundary + onReset re-triggering the fetch), reset-by-navigation, or "contact support". A fallback without an exit is a dead end.

And keep the coverage map honest at scale: boundaries don't catch async by design, so your data layer needs its own story. Query libraries can route fetch errors into the nearest boundary (throwOnError) precisely because they rethrow during render; everything else goes through showBoundary. An incident review that says "the boundary should have caught that fetch error" is a coverage-map failure, not a React bug.

Pattern smells

  • Boolean-prop explosion. hasHeader, showIcon, isCompact, withFooter... The component is accreting a config language for what composition already expresses. Each boolean roughly doubles the state space you claim to support.
  • God components. One component rendering three modes with if (variant === ...) forests. Split along the seams the conditionals reveal; share the logic with a hook rather than sharing the JSX with flags.
  • cloneElement APIs. They break under one wrapper element and shallow-merge props silently. Context (compound) replaced this; treat cloneElement in new API surface as a smell with one carve-out: the asChild pattern, where the clone contract is explicit and documented.
  • Prop-to-state syncing effects. useEffect(() => setInternal(props.value), [props.value]) inside a "controllable" component means state ownership was never decided. The dual-mode fork (middle notes) exists so this effect never does.
  • Drilling inside your own compound set. If Tabs passes props manually into Tabs.Tab, the Context wiring is missing and consumers can't wrap subcomponents.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more React