Build scalable component architectures with established design patterns.
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"
/>
);
}
value and onChange — no internal state for the value.value and use a ref via useRef for one-time reads (e.g., file inputs).onChange handler before state is updated.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>
useTheme) so consumers get a clean call-site API.type SelectContextValue<T> = {
value: T;
onChange: (next: T) => void;
};
function createSelectContext<T>() {
return createContext<SelectContextValue<T> | null>(null);
}
Select<string> and Select<number> both get full type inference on value and onChange, instead of widening everything to any the 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
}
React.Children.map with cloneElement is 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>;
}
cloneElement breaks 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 call useSelectContext(). Prefer Context for any compound API you expect consumers to wrap or rearrange; reserve cloneElement for sealed, internal component sets where you control every usage site.Children.toArray instead 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" />}
Select.Option usage outside <Select> is rejected by both the runtime guard and the type checker before the code ever ships.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>
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;
}
<Form> validation summary) where the producer needs to inject markup conditionally rather than just hand back values for the caller to render itself.React.memo on 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>
React.memo — for an unmemoized producer, the inline closure costs nothing extra since the producer re-renders with its parent regardless.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>
);
}
useUserList) rather than a wrapper component, but the separation of concerns stays the same.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.