React Component Patterns
Build scalable component architectures with established design patterns.
TL;DR
- 01Design controlled component APIs so parents own state via value and onChange props.
- 02Use the Provider Pattern to share state across a subtree without prop drilling.
- 03Pick compound components for closed sub-component sets, render props when callers control markup.
Tips
- 01If your component accepts both value (controlled) and defaultValue (uncontrolled), warn in development when a consumer switches between the two modes — React's own inputs do this and it prevents subtle bugs.
- 02Memoize the context value with useMemo when the provider re-renders frequently — otherwise every consumer re-renders even when the value has not changed.
- 03Export compound sub-components as static properties of the parent so the public API stays a single cohesive import instead of several loose named exports.
- 04When your container grows complex, extract it into a custom hook that returns { users, loading, error } — this keeps the JSX layer thin and moves all async logic to a testable function.
Warnings
- 01Avoid nesting render props three levels deep or overusing Context — both create tight coupling that makes components harder to test and reuse in isolation.
Controlled Component API
A controlled component exposes a value prop and an onChange callback so the parent owns the state entirely. This is React's "single source of truth" contract — the component never holds private internal state for the value it displays.
// Controlled input component
function TextInput({ value, onChange, placeholder }) {
return (
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// Parent owns the state
function LoginForm() {
const [email, setEmail] = useState('');
return (
<TextInput
value={email}
onChange={setEmail}
placeholder="Email address"
/>
);
}
- Fully controlled: parent passes
valueandonChange— no internal state for the value. - Uncontrolled fallback: omit
valueand use arefviauseReffor one-time reads (e.g., file inputs). - Validation and transformation happen in the parent's
onChangehandler before state is updated.
Provider Pattern
The Provider Pattern uses React Context to broadcast shared state — such as theme, locale, or authenticated user — to any descendant without threading props through every intermediate component. Unlike compound components, the consumers can be anywhere in the subtree and do not need to be specific child types.
const ThemeContext = createContext({ mode: 'light', toggle: () => {} });
function ThemeProvider({ children }) {
const [mode, setMode] = useState('light');
const toggle = () =>
setMode((m) => (m === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ mode, toggle }}>
{children}
</ThemeContext.Provider>
);
}
// Custom hook keeps consumers clean
function useTheme() {
return useContext(ThemeContext);
}
// Deep consumer — no prop drilling needed
function ThemeToggleButton() {
const { mode, toggle } = useTheme();
return <button onClick={toggle}>Switch to {mode === 'light' ? 'dark' : 'light'}</button>;
}
// App wires it together
<ThemeProvider>
<App /> {/* ThemeToggleButton anywhere inside has access */}
</ThemeProvider>
- Pair every Provider with a custom hook (
useTheme) so consumers get a clean call-site API. - Split contexts by update frequency — a slowly-changing locale and a rapidly-changing modal state belong in separate contexts to avoid unnecessary re-renders.
- Contrasted with compound components: Provider Pattern is for app-wide or subtree-wide concerns; compound components are for self-contained component suites.
Typed Compound Components
- At an advanced level, the interesting problem is not wiring a parent and child through Context — it is making the wiring type-safe and resilient to consumers reordering, omitting, or wrapping children. Start by typing the context value precisely and refusing a default object that could silently mask a missing provider.
type SelectContextValue<T> = { value: T; onChange: (next: T) => void; }; function createSelectContext<T>() { return createContext<SelectContextValue<T> | null>(null); } - A generic context factory lets
Select<string>andSelect<number>both get full type inference onvalueandonChange, instead of widening everything toanythe way a single shared context would.const SelectContext = createSelectContext<string>(); function useSelectContext() { const ctx = useContext(SelectContext); if (!ctx) throw new Error('Select.Option must be used inside <Select>'); return ctx; // narrowed to SelectContextValue<string>, never null past this line } - Context-based implicit sharing is convenient but invisible — a consumer cannot tell from JSX alone which children are required versus optional.
React.Children.mapwithcloneElementis the explicit alternative: the parent injects props directly into each child it recognizes, so the contract is visible in the child's own prop types rather than hidden behind a context read.function Select({ children, value, onChange }: SelectProps) { const items = Children.map(children, (child) => { if (!isValidElement<OptionProps>(child)) return child; return cloneElement(child, { selected: child.props.value === value, onSelect: () => onChange(child.props.value), }); }); return <div role="listbox">{items}</div>; } - Trade-off:
cloneElementbreaks if a consumer wraps an option in their own<div>or fragment — the clone only reaches direct children, so nesting silently drops the injected props. Context has no such restriction; any descendant, however deeply wrapped, can calluseSelectContext(). Prefer Context for any compound API you expect consumers to wrap or rearrange; reservecloneElementfor sealed, internal component sets where you control every usage site. - Build a flexible API by making the parent tolerant of missing, reordered, or extra children — derive an index from
Children.toArrayinstead of relying on prop-based IDs, so consumers can omit an item conditionally without renumbering the rest.const orderedIds = Children.toArray(children) .filter(isValidElement<OptionProps>) .map((child) => child.props.value); // Safe even if a caller does {showCanada && <Select.Option value="ca" />} - Export a discriminated union of sub-component prop types from the same module as the parent, so
Select.Optionusage outside<Select>is rejected by both the runtime guard and the type checker before the code ever ships.
Generic Render Prop APIs
- The basic mechanic — passing a function as a prop — is table stakes; the advanced concern is making that function's signature generic so the same component works across unrelated data shapes without consumers casting or duplicating the component.
type FetcherProps<T> = { url: string; children: (state: { data: T | null; error: Error | null; loading: boolean }) => React.ReactNode; }; function Fetcher<T>({ url, children }: FetcherProps<T>) { const [state, setState] = useState<{ data: T | null; error: Error | null; loading: boolean }>( { data: null, error: null, loading: true } ); useEffect(() => { let cancelled = false; fetch(url) .then((r) => r.json()) .then((data: T) => !cancelled && setState({ data, error: null, loading: false })) .catch((error) => !cancelled && setState({ data: null, error, loading: false })); return () => { cancelled = true; }; }, [url]); return <>{children(state)}</>; } // T is inferred at the call site — no casting needed <Fetcher<User[]> url="/api/users"> {({ data, loading }) => (loading ? <Spinner /> : <UserList users={data ?? []} />)} </Fetcher> - The modern hooks-based alternative to this exact component is a generic
useFetcher<T>(url)hook that returns the same{ data, error, loading }shape — for a component-tree consumer that already has hooks available, the hook is simpler because it adds no nesting and no extra component to the React tree.function useFetcher<T>(url: string) { const [state, setState] = useState<{ data: T | null; error: Error | null; loading: boolean }>( { data: null, error: null, loading: true } ); useEffect(() => { let cancelled = false; fetch(url).then((r) => r.json()).then((data: T) => !cancelled && setState({ data, error: null, loading: false })); return () => { cancelled = true; }; }, [url]); return state; } - Render props are still the better choice in three cases the hook cannot cover: class component consumers (which cannot call hooks at all), library code that must support both hook and non-hook consumers from one component, and JSX-driven APIs (like a
<Form>validation summary) where the producer needs to inject markup conditionally rather than just hand back values for the caller to render itself. - Watch for the classic render-prop performance trap: defining the render function inline causes React to see a new function reference every parent render, which defeats
React.memoon the render-prop component itself (though it does not affect the children the function returns).// Bad inside a frequently re-rendering parent: new closure every render <MemoizedFetcher<User[]> url={url}> {(state) => <UserList {...state} />} </MemoizedFetcher> // Better: stable reference via useCallback when the producer is memoized const renderUsers = useCallback( (state: FetchState<User[]>) => <UserList {...state} />, [] ); <MemoizedFetcher<User[]> url={url}>{renderUsers}</MemoizedFetcher> - This only matters when the render-prop component itself is wrapped in
React.memo— for an unmemoized producer, the inline closure costs nothing extra since the producer re-renders with its parent regardless.
Container/Presentational Pattern
The Container/Presentational pattern separates data-fetching logic from pure rendering. The container handles side effects, state, and API calls; the presentational component is a pure function of props with no side effects, making it trivially unit-testable and reusable across different data sources.
// Container: owns data fetching and state
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users')
.then((r) => r.json())
.then((data) => { setUsers(data); setLoading(false); });
}, []);
if (loading) return <p>Loading...</p>;
return <UserList users={users} />;
}
// Presentational: pure UI, no side effects
function UserList({ users }) {
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
- Container components are responsible for what data is shown; presentational components control how it looks.
- Presentational components are easy to test with hardcoded prop data — no mocking required.
- In a hooks world, the container is often a custom hook (
useUserList) rather than a wrapper component, but the separation of concerns stays the same. - Useful for Storybook: presentational components can be developed and documented with mock props independent of any API.
FAQ
A controlled component accepts its current value and an onChange callback as props, giving the parent full ownership of state. This makes the component predictable and testable — the parent can validate, transform, or synchronize the value before passing it back. It matters for API design because it establishes a clear contract: the component renders what it receives and reports changes upward; it never holds private state that can drift out of sync.
Both use React Context internally, but they serve different purposes. The Provider Pattern is about broadcasting shared state (like theme or auth) to any descendant in a subtree — consumers can be deeply nested and unrelated. Compound components are a tightly scoped API where a specific set of child components (like Tabs.Tab and Tabs.Panel) cooperate with a parent through a private context. Use Provider for app-wide concerns; use compound components for self-contained UI kits.
Use render props when the consumer needs to control the rendered output for each state a component produces, like loading, error, and success markup. A custom hook only returns values and functions — it cannot inject JSX on the producer's behalf. If consumers only need data, not markup control, a custom hook is simpler and avoids the extra nesting.
The shared context defaults to null, and the access hook throws an error if it reads null, which happens whenever a sub-component renders outside its parent's provider. Typing the context as Context<T | null> in TypeScript means this misuse is also caught at compile time, not just at runtime.
Because a presentational component is a pure function of props, you can render it directly in a test with whatever props you need — no mocks, no providers, no network calls. Pass a hardcoded array of user objects and assert on the rendered output. The container (or hook) that fetches real data is tested separately with mocked fetch or an msw handler, keeping the two concerns cleanly separated.