⚛React component patterns

Short examples of the patterns that come up in senior React interviews and design system work. Interview answers: /notes/technical-job-interview-questions/#react-questions.

Compound components

Parts share state through context, so the consumer controls the markup.

const TabsContext = createContext<{ active: string; setActive: (id: string) => void } | null>(null)

function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) {
  const [active, setActive] = useState(defaultValue)
  return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>
}

function Tab({ id, children }: { id: string; children: ReactNode }) {
  const ctx = useContext(TabsContext)!
  return (
    <button role="tab" aria-selected={ctx.active === id} onClick={() => ctx.setActive(id)}>
      {children}
    </button>
  )
}

function Panel({ id, children }: { id: string; children: ReactNode }) {
  const ctx = useContext(TabsContext)!
  return ctx.active === id ? <div role="tabpanel">{children}</div> : null
}

Tabs.Tab = Tab
Tabs.Panel = Panel

// usage
<Tabs defaultValue="a">
  <Tabs.Tab id="a">A</Tabs.Tab>
  <Tabs.Tab id="b">B</Tabs.Tab>
  <Tabs.Panel id="a">Panel A</Tabs.Panel>
  <Tabs.Panel id="b">Panel B</Tabs.Panel>
</Tabs>

More: /notes/react-compound-components/

Polymorphic components (as prop)

One component, any element, with the right props for that element.

type BoxProps<T extends ElementType> = {
  as?: T
} & ComponentPropsWithoutRef<T>

function Box<T extends ElementType = 'div'>({ as, ...props }: BoxProps<T>) {
  const Component = as ?? 'div'
  return <Component {...props} />
}

<Box as="a" href="/cv">CV</Box> // href is allowed
<Box as="button" type="submit">Send</Box> // type is allowed

Radix uses asChild instead: the component merges its props onto its only child (<Slot>), so you keep your own element.

Headless components

Logic and a11y in a hook, styling left to the consumer. Radix, React Aria and TanStack work like this.

function useDisclosure(initial = false) {
  const [open, setOpen] = useState(initial)
  const id = useId()
  return {
    open,
    getButtonProps: () => ({ 'aria-expanded': open, 'aria-controls': id, onClick: () => setOpen((o) => !o) }),
    getPanelProps: () => ({ id, hidden: !open }),
  }
}

function Faq() {
  const { getButtonProps, getPanelProps } = useDisclosure()
  return (
    <>
      <button className="my-styles" {...getButtonProps()}>Question</button>
      <div {...getPanelProps()}>Answer</div>
    </>
  )
}

State reducer

Let consumers change how internal state moves. Kent C. Dodds’ pattern, used by Downshift.

type Action = { type: 'toggle' } | { type: 'reset' }

function toggleReducer(state: { on: boolean }, action: Action) {
  switch (action.type) {
    case 'toggle':
      return { on: !state.on }
    case 'reset':
      return { on: false }
  }
}

function useToggle({ reducer = toggleReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, { on: false })
  return { on: state.on, toggle: () => dispatch({ type: 'toggle' }) }
}

// consumer: never allow it to turn off
const { on, toggle } = useToggle({
  reducer: (state, action) => (action.type === 'toggle' && state.on ? state : toggleReducer(state, action)),
})

Controlled and uncontrolled

A component lib should support both, like <input value> and <input defaultValue>.

function useControllableState<T>({ value, defaultValue, onChange }: {
  value?: T
  defaultValue: T
  onChange?: (value: T) => void
}) {
  const [internal, setInternal] = useState(defaultValue)
  const isControlled = value !== undefined
  const current = isControlled ? value : internal
  const setValue = (next: T) => {
    if (!isControlled) setInternal(next)
    onChange?.(next)
  }
  return [current, setValue] as const
}

Refs and imperative APIs

In React 19 ref is a normal prop, so forwardRef is no longer needed. Use useImperativeHandle to expose a small API (focus, scroll) and not the whole DOM node.

function SearchInput({ ref }: { ref: Ref<{ focus: () => void; clear: () => void }> }) {
  const inputRef = useRef<HTMLInputElement>(null)
  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    clear: () => {
      if (inputRef.current) inputRef.current.value = ''
    },
  }))
  return <input ref={inputRef} />
}

Older API: /notes/react-forwardref/

Context splitting

Every consumer re-renders when the context value changes. Put state and dispatch in separate contexts, so components that only dispatch don’t re-render.

const CountState = createContext(0)
const CountDispatch = createContext<Dispatch<Action>>(() => {})

function CountProvider({ children }: { children: ReactNode }) {
  const [count, dispatch] = useReducer(reducer, 0)
  return (
    <CountDispatch.Provider value={dispatch}>
      <CountState.Provider value={count}>{children}</CountState.Provider>
    </CountDispatch.Provider>
  )
}

For fast-changing state, use a store with selectors (Zustand, Jotai) instead of context.

useSyncExternalStore

Subscribe to a store that lives outside React, without tearing (different parts of the UI showing different values).

function subscribe(callback: () => void) {
  window.addEventListener('online', callback)
  window.addEventListener('offline', callback)
  return () => {
    window.removeEventListener('online', callback)
    window.removeEventListener('offline', callback)
  }
}

function useOnline() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine, // client snapshot
    () => true, // server snapshot
  )
}

Keep input responsive: useTransition and useDeferredValue

Mark slow updates as low priority, so typing never waits for them.

function Search() {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <SlowResults query={deferredQuery} />
    </>
  )
}

// or, when you own the state update
const [isPending, startTransition] = useTransition()
startTransition(() => setTab('reports'))

SlowResults must be wrapped in memo, or it re-renders with every key press anyway.

Optimistic UI: useOptimistic

Show the result at once. React drops the optimistic value when the action finishes, so on failure it rolls back by itself.

function Likes({ count, like }: { count: number; like: () => Promise<void> }) {
  const [optimisticCount, addOptimistic] = useOptimistic(count, (current, n: number) => current + n)

  return (
    <form
      action={async () => {
        addOptimistic(1)
        await like() // throws → count stays as it was
      }}
    >
      <button type="submit">♥ {optimisticCount}</button>
    </form>
  )
}

TanStack Query version: /notes/react-query/#optimistic-updates

Error boundaries and Suspense

Declarative loading and error states. Put the boundary where the fallback makes sense, not around every component.

<ErrorBoundary fallback={<p>Could not load comments</p>}>
  <Suspense fallback={<Spinner />}>
    <Comments />
  </Suspense>
</ErrorBoundary>

More: /notes/react-errorboundary/