Composition Patterns
Composition is the base pattern
Section titled “Composition is the base pattern”React’s answer to reuse is not inheritance — it is composition. Instead of extending a component, you pass content and behavior into it. The simplest form is the children prop: a component accepts arbitrary JSX and decides where to place it.
// A generic Card that doesn't know or care what's inside it.function Card({ title, children }) { return ( <section className="card"> <h3>{title}</h3> <div className="card-body">{children}</div> </section> );}
<Card title="Profile"> <Avatar user={user} /> <p>{user.bio}</p></Card>Slots via props: more than one children
Section titled “Slots via props: more than one children”You can pass JSX through any prop, not just children — a component with several independent regions of content can accept one prop per slot, typed so each region only ever receives renderable content:
import type { ReactNode } from 'react';
export function PageLayout({ sidebar, main, footer,}: { sidebar: ReactNode; main: ReactNode; footer?: ReactNode;}) { return ( <div className="page"> <aside className="page__sidebar">{sidebar}</aside> <main className="page__main">{main}</main> {footer && <footer className="page__footer">{footer}</footer>} </div> );}<PageLayout sidebar={<Nav />} main={<Feed />} footer={<Copyright />} />This is how you avoid deep prop-drilling and rigid inheritance trees: each slot is just a normal prop, so it’s typed, optional or required like any other, and the layout component never needs to know what’s actually inside.
Compound components, with typed context
Section titled “Compound components, with typed context”When several components need to work together and share state, the compound component pattern exposes a family of pieces that coordinate through a shared (usually context-based) state — while the consumer arranges them freely.
// The consumer composes the pieces; Tabs shares state via context internally.<Tabs defaultValue="a"> <TabList> <Tab value="a">First</Tab> <Tab value="b">Second</Tab> </TabList> <TabPanel value="a">Panel A</TabPanel> <TabPanel value="b">Panel B</TabPanel></Tabs>flowchart TB tabs["Tabs (provides value + setValue via context)"] --> list["TabList"] list --> tab["Tab (reads context, calls setValue)"] tabs --> panel["TabPanel (reads context, shows if active)"]
Internally, Tabs provides the active value + a setter via context; Tab and TabPanel read it with useContext. The consumer gets a clean, declarative API and full control over layout, without threading props through every piece. This is how libraries like Radix and Headless UI structure their components.
Proof: shared state through context, and a typed guard against misuse
Section titled “Proof: shared state through context, and a typed guard against misuse”import { createContext, useContext, useState, type ReactNode } from 'react';
type TabsContextValue = { active: string; setActive: (value: string) => void };const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext(component: string): TabsContextValue { const ctx = useContext(TabsContext); if (!ctx) throw new Error(`<${component}> must be rendered inside <Tabs>`); return ctx;}
export function Tabs({ defaultValue, children }: { defaultValue: string; children: ReactNode }) { const [active, setActive] = useState(defaultValue); return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>;}
export function TabList({ children }: { children: ReactNode }) { return <div role="tablist">{children}</div>;}
export function Tab({ value, children }: { value: string; children: ReactNode }) { const { active, setActive } = useTabsContext('Tab'); return ( <button role="tab" aria-selected={active === value} onClick={() => setActive(value)}> {children} </button> );}
export function TabPanel({ value, children }: { value: string; children: ReactNode }) { const { active } = useTabsContext('TabPanel'); if (active !== value) return null; return <div role="tabpanel">{children}</div>;}import { render, screen, fireEvent } from '@testing-library/react';import { Tabs, TabList, Tab, TabPanel } from '../components/Tabs';
test('compound components share active state through context, no prop drilling', () => { render( <Tabs defaultValue="a"> <TabList> <Tab value="a">First</Tab> <Tab value="b">Second</Tab> </TabList> <TabPanel value="a">Panel A</TabPanel> <TabPanel value="b">Panel B</TabPanel> </Tabs>, );
expect(screen.getByText('Panel A')).toBeTruthy(); expect(screen.queryByText('Panel B')).toBeNull();
fireEvent.click(screen.getByText('Second'));
expect(screen.queryByText('Panel A')).toBeNull(); expect(screen.getByText('Panel B')).toBeTruthy();});
test('a piece used outside <Tabs> throws with a clear error, proving the context requirement', () => { function BrokenTab() { return <Tab value="a">oops</Tab>; } expect(() => render(<BrokenTab />)).toThrow('<Tab> must be rendered inside <Tabs>');});✓ compound components share active state through context, no prop drilling✓ a piece used outside <Tabs> throws with a clear error, proving the context requirement
Test Files 1 passed (1) Tests 2 passed (2)TabsContextValue | null (rather than defaulting the context to a fake value) is what makes useTabsContext’s runtime guard possible and type-safe: TypeScript forces every reader to handle the null case, and the thrown error gives a precise, actionable message instead of a confusing “cannot read property of undefined” deeper in the component.
Render props, and why hooks replaced most of them
Section titled “Render props, and why hooks replaced most of them”A render prop is a prop whose value is a function that returns JSX — letting a component share logic while the caller controls rendering.
// Render prop: MouseTracker owns the logic, the caller renders.<MouseTracker render={({ x, y }) => <p>Cursor at {x}, {y}</p>} />Render props were the classic way to share stateful logic before hooks. Today, a custom hook does the same thing more directly — no wrapper component, no nesting:
// The modern equivalent: a custom hook.function Component() { const { x, y } = useMousePosition(); // logic shared via a hook return <p>Cursor at {x}, {y}</p>; // caller renders normally}You will still see render props (some libraries use them, and children-as-a-function is one), but for sharing your own logic, reach for a custom hook first.
children as a function, typed with generics
Section titled “children as a function, typed with generics”The most common surviving render-prop shape is children itself being a function — useful when a component owns a collection and wants the caller to control how each item renders, without giving up type safety on what an item actually is:
import type { ReactNode } from 'react';
export function DataList<T>({ items, children,}: { items: T[]; children: (item: T, index: number) => ReactNode;}) { return ( <ul> {items.map((item, i) => ( <li key={i}>{children(item, i)}</li> ))} </ul> );}import { render, screen } from '@testing-library/react';import { DataList } from '../components/DataList';
test('DataList<T> infers T from `items` — string items give `item` a string type', () => { render(<DataList items={['a', 'b']}>{(item) => <span>{item.toUpperCase()}</span>}</DataList>); expect(screen.getByText('A')).toBeTruthy(); expect(screen.getByText('B')).toBeTruthy();});
test('DataList<T> infers an object shape from `items` — no cast needed to read its fields', () => { const items = [{ id: 1, label: 'One' }, { id: 2, label: 'Two' }]; render(<DataList items={items}>{(item, index) => <span>{index}:{item.label}</span>}</DataList>); expect(screen.getByText('0:One')).toBeTruthy(); expect(screen.getByText('1:Two')).toBeTruthy();});✓ DataList<T> infers T from `items` — string items give `item` a string type✓ DataList<T> infers an object shape from `items` — no cast needed to read its fields
Test Files 1 passed (1) Tests 2 passed (2)Note what item.toUpperCase() and item.label prove here: because DataList is generic over T, TypeScript infers T from whatever array you pass as items and threads that exact type through to the children callback’s parameter — a string[] gives you .toUpperCase() with no cast, and an object array gives you .label with no cast. Get the item’s shape wrong and this is a compile error, not a runtime surprise.
Polymorphic components: typing the as prop
Section titled “Polymorphic components: typing the as prop”A polymorphic component renders as whichever HTML element (or component) the caller picks via an as prop, while still type-checking that element’s own specific props — <Text as="a" href="..."> should require href; <Text as="button" onClick={...}> should require onClick; a plain <Text> shouldn’t require either.
import type { ElementType, ComponentPropsWithoutRef, ReactNode } from 'react';
type PolymorphicProps<E extends ElementType> = { as?: E; children?: ReactNode;} & Omit<ComponentPropsWithoutRef<E>, 'as' | 'children'>;
export function Text<E extends ElementType = 'span'>({ as, children, ...rest }: PolymorphicProps<E>) { const Component = as || 'span'; return <Component {...rest}>{children}</Component>;}import { render, screen } from '@testing-library/react';import { Text } from '../components/PolymorphicText';
test('renders as <span> by default', () => { render(<Text>hello</Text>); expect(screen.getByText('hello').tagName).toBe('SPAN');});
test('as="a" renders an anchor and type-checks its own props (href)', () => { render(<Text as="a" href="/docs">link</Text>); const el = screen.getByText('link'); expect(el.tagName).toBe('A'); expect(el.getAttribute('href')).toBe('/docs');});
test('as="button" accepts onClick, proving prop types follow the `as` element', () => { const onClick = vi.fn(); render( <Text as="button" onClick={onClick}> click </Text>, ); screen.getByText('click').click(); expect(onClick).toHaveBeenCalledTimes(1);});✓ renders as <span> by default✓ as="a" renders an anchor and type-checks its own props (href)✓ as="button" accepts onClick, proving prop types follow the `as` element
Test Files 1 passed (1) Tests 3 passed (3)The generic default E extends ElementType = 'span' is what makes the plain, as-less case work with no extra ceremony, and ComponentPropsWithoutRef<E> is what re-derives the correct prop set every time E changes — this single component type-checks correctly against every HTML element it could render as, without a manual overload per tag.
Higher-order components: pitfalls and why hooks replaced most of them
Section titled “Higher-order components: pitfalls and why hooks replaced most of them”A higher-order component (HOC) is a function that takes a component and returns a new, wrapped component — the pre-hooks way to share cross-cutting behavior (logging, subscriptions, injected props). Two pitfalls show up constantly in HOC code:
- Ref forwarding. A ref attached to the wrapped component has to be explicitly threaded through the HOC — otherwise it silently resolves to
nullfor the caller. - Display name. Without one, every HOC-wrapped component shows up in React DevTools as the wrapper’s generic name, not the component it actually wraps, making the component tree unreadable.
import type { Ref } from 'react';
type InputProps = { placeholder?: string; ref?: Ref<HTMLInputElement> };
function PlainInput({ placeholder, ref }: InputProps) { return <input placeholder={placeholder} ref={ref} />;}
// ❌ Broken: destructures `ref` out along with the rest of props, but never// passes it anywhere — the caller's ref is silently dropped.function withLoggingBroken(Wrapped: typeof PlainInput) { return function Logged(props: InputProps) { const { ref, ...rest } = props; return <Wrapped {...rest} />; };}
// ✅ Fixed: forward `ref` straight through — React 19 treats it as a plain// prop, no forwardRef needed — and set a displayName for DevTools.function withLoggingFixed(Wrapped: typeof PlainInput) { function Logged({ ref, ...rest }: InputProps) { return <Wrapped {...rest} ref={ref} />; } Logged.displayName = `withLogging(${Wrapped.name})`; return Logged;}
export { PlainInput, withLoggingBroken, withLoggingFixed };import { render } from '@testing-library/react';import { PlainInput, withLoggingBroken, withLoggingFixed } from '../components/withLogging';
const LoggedBroken = withLoggingBroken(PlainInput);const LoggedFixed = withLoggingFixed(PlainInput);
test('a HOC that does not forward ref leaves the caller\'s ref null', () => { const captured: { current: HTMLInputElement | null } = { current: null }; render(<LoggedBroken ref={(node) => { captured.current = node; }} />); expect(captured.current).toBeNull();});
test('forwarding ref through the HOC gives the caller the real DOM node', () => { const captured: { current: HTMLInputElement | null } = { current: null }; render(<LoggedFixed ref={(node) => { captured.current = node; }} />); expect(captured.current).not.toBeNull(); expect(captured.current?.tagName).toBe('INPUT');});
test('the fixed HOC sets a readable displayName instead of the generic wrapper name', () => { expect(LoggedFixed.displayName).toBe('withLogging(PlainInput)');});✓ a HOC that does not forward ref leaves the caller's ref null✓ forwarding ref through the HOC gives the caller the real DOM node✓ the fixed HOC sets a readable displayName instead of the generic wrapper name
Test Files 1 passed (1) Tests 3 passed (3)Both pitfalls are exactly why hooks replaced most HOC usage: a custom hook has no wrapper component at all, so there is no ref to lose and no extra layer to name in DevTools — the cross-cutting logic lives in the hook, and the component that uses it stays exactly itself in the tree.
Controlled vs uncontrolled
Section titled “Controlled vs uncontrolled”A form input is controlled when React state is its single source of truth (value + onChange), and uncontrolled when the DOM holds the value and you read it via a ref (or, in React 19, from the form’s FormData on submit).
// Controlled: state drives the input; you can validate/transform on every keystroke.const [name, setName] = useState("");<input value={name} onChange={(e) => setName(e.target.value)} />
// Uncontrolled: the DOM owns the value; read it when you need it.const ref = useRef(null);<input defaultValue="Ada" ref={ref} /> // ref.current.value on submitUse controlled when you need to react to or constrain input as it changes; uncontrolled for simple forms where you only care about the final value (less state, less re-rendering).
Mental Model
Section titled “Mental Model”Every pattern on this page is the same move: decide who owns a piece of the UI and how the pieces agree on a contract. children/slots hand a whole subtree to the caller with no contract beyond “renders something.” Compound components share a private contract (context) among a fixed family of pieces. children-as-a-function and polymorphic as push the contract into the type system — the caller and the component negotiate exactly what shape of data or props flows across the boundary, checked at compile time instead of discovered at runtime. A HOC tries to inject behavior without a contract with the wrapped component at all, which is exactly why it needs manual patches (ref forwarding, display names) that a hook never has to think about.
Trade-offs
Section titled “Trade-offs”| Pattern | Benefit | Cost |
|---|---|---|
children / slots | Simplest possible reuse; caller controls content fully | No shared state between the parts unless you add context |
| Compound components | Clean declarative API; shared state with no prop drilling | Pieces are coupled to their specific parent’s context; less portable alone |
children as a function (generics) | Type-safe per-item rendering the component itself never has to know about | Slightly more ceremony than a plain children: ReactNode prop |
Polymorphic as prop | One component, correct prop types for every element it can render as | The generic type machinery is non-trivial to read and maintain |
| Higher-order component | Works without hooks; can wrap class components | Ref forwarding and display names must be handled by hand; adds a wrapper layer |
| Custom hook (instead of a HOC) | No wrapper component, no ref/display-name pitfalls | Cannot inject markup around the caller — only shares logic/state |
Common Misconceptions
Section titled “Common Misconceptions”- “
childrenis the only way to compose components.” Proven above: any prop can carry JSX — slots via named props are just as much composition aschildren. - “Compound components need prop drilling to share state.” Proven above: the pieces share state through context, set up once inside the family’s root component — no prop threading required.
- “Generic components lose type safety once you accept
childrenas a function.” Proven above: TypeScript infers the exact item type fromitemsand carries it into the callback parameter — there is noanyinvolved. - “A HOC and a custom hook are interchangeable.” A HOC wraps the tree (it can add markup, and it has ref/display-name pitfalls to manage); a hook only shares logic and state, with nothing to wrap and nothing to forward.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated code that uses these composition patterns, check for:
- Does a higher-order component destructure and drop
refinstead of forwarding it? The caller’s ref will silently benull(proven above). - Does a HOC-wrapped component have a
displayNameset, or will it show up in React DevTools under the wrapper’s generic name? - Is a compound component’s context typed as non-nullable with a fake default value, hiding the “used outside its parent” bug instead of throwing a clear error?
- Could a render-prop or HOC-based pattern be replaced with a plain custom hook, removing an unnecessary wrapper layer entirely?
- Does a component claiming to be “polymorphic” (an
asprop) actually re-type its own props per element, or does it fall back toany/overly-loose prop types that would accept an invalid combination (likehrefon abutton)?
🐛 Spot the bug
function withAnalytics(Wrapped) {function Tracked(props) { return <Wrapped {...props} />;}return Tracked;}
const TrackedButton = withAnalytics(function Button({ ref, ...props }) {return <button ref={ref} {...props} />;});
function Toolbar() {const buttonRef = useRef(null);return <TrackedButton ref={buttonRef} onClick={() => buttonRef.current.focus()} />;}Show the answer
Tracked spreads {...props} onto Wrapped, but a ref is not an ordinary prop that spreading passes along automatically the way a real prop is — Tracked itself never declares ref in its own parameter list, so the ref given to <TrackedButton ref={buttonRef} /> is attached to Tracked, not threaded through to the inner Button. buttonRef.current ends up pointing at whatever Tracked exposes (nothing, since it’s a plain function component that doesn’t declare a ref prop) rather than the actual <button> DOM node, so buttonRef.current.focus() throws. The fix is for Tracked to explicitly accept and forward ref, exactly like withLoggingFixed above: function Tracked({ ref, ...props }) { return <Wrapped {...props} ref={ref} />; }.