Topics
v5.0.0
On this page · sections

Component Composition & APIs

React composition patterns let you build flexible, reusable UIs: compound components share implicit state, controlled/uncontrolled modes define source of truth, polymorphic components swap underlying elements, and children-as-props isolates heavy subtrees from hot parent state.

PatternCore ideaBest for
Compound componentsShared state via Context between siblingsDesign systems, tabs, accordions
Controlled / uncontrolledReact state vs DOM as source of truthForms, inputs, widgets
Polymorphic as / asChildOne implementation, swappable rendererButtons, links, primitives
Children-as-propsStable element identity skips reconcileDashboard shells, layout slots

Compound Components

Definition

A family of components that share implicit state through Context — the consumer controls structure and styling while the root coordinates behavior.

'use client'; import { createContext, useContext, useId, useMemo, useState, type ReactNode } from 'react'; type TabsContextValue = { active: string; setActive: (id: string) => void; baseId: string }; const TabsContext = createContext<TabsContextValue | null>(null); function useTabsContext() { const ctx = useContext(TabsContext); if (!ctx) throw new Error('Tabs.* must be used within <Tabs>'); return ctx; } function Tabs({ defaultValue = 'tab-1', value, onValueChange, children, }: { defaultValue?: string; value?: string; onValueChange?: (value: string) => void; children: ReactNode; }) { const baseId = useId(); const [internal, setInternal] = useState(defaultValue); const active = value ?? internal; const setActive = (id: string) => { if (value === undefined) setInternal(id); onValueChange?.(id); }; const ctx = useMemo(() => ({ active, setActive, baseId }), [active, baseId]); return <TabsContext.Provider value={ctx}>{children}</TabsContext.Provider>; } function TabsTrigger({ value, children }: { value: string; children: ReactNode }) { const { active, setActive, baseId } = useTabsContext(); const selected = active === value; return ( <button type="button" role="tab" id={`${baseId}-trigger-${value}`} aria-selected={selected} aria-controls={`${baseId}-panel-${value}`} tabIndex={selected ? 0 : -1} onClick={() => setActive(value)} > {children} </button> ); } Tabs.Trigger = TabsTrigger;

Compound needs shared coordinated state between siblings. Export domain actions (toggle, select), not raw setState. Memoize context value to avoid unrelated re-renders.

Interview: Prefer Context over legacy cloneElement — Context scales better and does not break ref forwarding.

Controlled vs Uncontrolled

Definition

Controlled components use value + onChange — React state is the source of truth every render. Uncontrolled components use defaultValue + ref — the DOM owns the value after mount.

'use client'; import { useRef, useState, type FormEvent } from 'react'; function ControlledName() { const [name, setName] = useState(''); return ( <input value={name} onChange={(event) => setName(event.target.value)} placeholder="Controlled" /> ); } function UncontrolledName() { const inputRef = useRef<HTMLInputElement>(null); function handleSubmit(event: FormEvent) { event.preventDefault(); console.log(inputRef.current?.value); } return ( <form onSubmit={handleSubmit}> <input ref={inputRef} defaultValue="" placeholder="Uncontrolled" /> <button type="submit">Save</button> </form> ); }
Controlled → keystroke → onChange → setState → re-render → value prop → DOM Uncontrolled → keystroke → browser updates DOM; read ref on submit

Choose controlled for live validation, character counts, dependent fields, URL sync. Choose uncontrolled for simple submit-only forms or React Hook Form register().

Interview: defaultValue applies only on initial mount. To reset, remount with a key or switch to controlled state.

Real-world example

React 19 Server Actions with useActionState:

'use client'; import { useActionState } from 'react'; type FormState = { error?: string; ok?: boolean }; async function createPost(_prev: FormState, formData: FormData): Promise<FormState> { const title = String(formData.get('title') ?? ''); if (title.length < 3) return { error: 'Title too short' }; return { ok: true }; } function CreatePostForm() { const [state, formAction, pending] = useActionState(createPost, {}); return ( <form action={formAction}> <input name="title" defaultValue="" disabled={pending} /> {state.error && <p role="alert">{state.error}</p>} <button type="submit" disabled={pending}>Save</button> </form> ); }

File inputs are always uncontrolled from React’s perspective — store File in state and remount with key to clear.

Polymorphic Components

Definition

A polymorphic component renders as different underlying elements via an as prop or Radix asChild + Slot — one implementation, swappable renderer.

'use client'; import { forwardRef, type ComponentPropsWithoutRef, type ElementType, } from 'react'; type ButtonProps<T extends ElementType = 'button'> = { as?: T; variant?: 'primary' | 'ghost'; } & ComponentPropsWithoutRef<T>; function ButtonInner<T extends ElementType = 'button'>( { as, variant = 'primary', className, ...props }: ButtonProps<T>, ref: React.Ref<Element>, ) { const Comp = as ?? 'button'; const classes = `btn btn--${variant} ${className ?? ''}`; return <Comp ref={ref} className={classes} {...props} />; } export const Button = forwardRef(ButtonInner) as <T extends ElementType = 'button'>( props: ButtonProps<T> & { ref?: React.Ref<Element> }, ) => React.ReactElement | null;

Radix Slot and asChild

Merge className, event handlers, and ref onto the consumer’s single child — no wrapper DOM node:

'use client'; import { Slot } from '@radix-ui/react-slot'; import { forwardRef, type ButtonHTMLAttributes } from 'react'; type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }; export const PrimitiveButton = forwardRef<HTMLButtonElement, ButtonProps>( function PrimitiveButton({ asChild, className, ...props }, ref) { const Comp = asChild ? Slot : 'button'; return <Comp ref={ref} className={`btn ${className ?? ''}`} {...props} />; }, ); // <PrimitiveButton asChild><a href="/dashboard">Dashboard</a></PrimitiveButton>
as prop → primitive picks the tag internally asChild + Slot → consumer supplies the element; props merge onto it

Interview: Next.js Link: <Button as={Link} href="/dashboard"> — Link must forward ref. Slot throws on multiple children.

Children-as-Props & Composition

Definition

Pre-built JSX passed as children from a stable scope lets React skip subtree reconciliation — structural isolation, not magic memoization.

'use client'; import { useState, type ReactNode } from 'react'; function ExpensiveList() { return ( <ul> {Array.from({ length: 5000 }, (_, i) => ( <li key={i}>Row {i}</li> ))} </ul> ); } // ❌ Inline — new element every parent render function BadShell() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount((c) => c + 1)}>{count}</button> <ExpensiveList /> </div> ); } // ✅ Lifted element — stable reference const stableList = <ExpensiveList />; function GoodShell({ children }: { children: ReactNode }) { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount((c) => c + 1)}>{count}</button> {children} </div> ); }

Named slots

Use named ReactNode props for shells with multiple independent regions:

type PageShellProps = { header: ReactNode; sidebar?: ReactNode; children: ReactNode; }; function PageShell({ header, sidebar, children }: PageShellProps) { return ( <div className="grid"> <header>{header}</header> {sidebar && <aside>{sidebar}</aside>} <main>{children}</main> </div> ); }

Pass elements, not component types — rendering <Content /> inside the shell recreates the element every parent render.

Interview: Composition moves volatile state outside the subtree. Restructure first, React.memo second.

Senior Interview Summary

What to say

Compound components share implicit state via Context between named siblings — export guarded hooks and controlled/uncontrolled root modes. Controlled means React owns the value; uncontrolled means the DOM does. Polymorphic components swap renderers with as or asChild. Pre-built JSX as children from a stable scope skips subtree reconciliation.

Common pitfalls

"Compound = any component with children" → needs shared sibling state "defaultValue updates on prop change" → only initial mount; use key "as='div' for buttons" → needs role, tabIndex, keyboard "Inline JSX in hot parent" → new element every render "Children never re-render" → wrong under hot provider or inline JSX

Practical rule

Public design system primitive → compound + ARIA + asChild on triggers Live validation / URL sync → controlled Server Action simple mutation → uncontrolled + useActionState Dashboard shell → stable grid children from layout + counter island Interactive primitive → polymorphic or asChild; layout → wrapper div

Last updated on