React & UI Patterns
React has developed its own vocabulary of patterns over the years. Higher-Order Components (HOCs) and Render Props dominated the class component era, but hooks have become the primary tool for logic reuse. This article covers the patterns that matter in modern React: custom hooks, compound components, container/presenter separation, and controlled components.
Custom Hooks
Custom hooks are the standard way to extract and reuse stateful logic in React. They're just functions that call other hooks—the use prefix signals to React (and ESLint) that hook rules apply.
Basic Structure
// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// Initialize from localStorage or use default
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
// Update localStorage when value changes
useEffect(() => {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(key, JSON.stringify(storedValue));
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error);
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
// Usage
function Settings() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [fontSize, setFontSize] = useLocalStorage('fontSize', 16);
return (
<div>
<select value={theme} onChange={(e) => setTheme(e.target.value)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input
type="range"
min={12}
max={24}
value={fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
/>
</div>
);
}
Encapsulating Side Effects
Hooks excel at hiding complex async logic:
// src/hooks/useFetch.ts
import { useState, useEffect, useCallback, useRef } from 'react';
interface UseFetchResult<T> {
data: T | null;
error: Error | null;
isLoading: boolean;
refetch: () => void;
}
interface UseFetchOptions {
enabled?: boolean;
refetchInterval?: number;
}
export function useFetch<T>(
url: string,
options: UseFetchOptions = {}
): UseFetchResult<T> {
const { enabled = true, refetchInterval } = options;
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Track mounted state to prevent updates after unmount
const mountedRef = useRef(true);
const fetchData = useCallback(async () => {
if (!enabled) return;
setIsLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const json = await response.json();
if (mountedRef.current) {
setData(json);
}
} catch (err) {
if (mountedRef.current) {
setError(err instanceof Error ? err : new Error(String(err)));
}
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
}
}, [url, enabled]);
// Initial fetch and refetch on URL change
useEffect(() => {
fetchData();
}, [fetchData]);
// Optional polling
useEffect(() => {
if (!refetchInterval || !enabled) return;
const intervalId = setInterval(fetchData, refetchInterval);
return () => clearInterval(intervalId);
}, [fetchData, refetchInterval, enabled]);
// Cleanup on unmount
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
return { data, error, isLoading, refetch: fetchData };
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, error, isLoading, refetch } = useFetch<User>(
`/api/users/${userId}`,
{ refetchInterval: 30000 } // Poll every 30 seconds
);
if (isLoading && !user) return <Skeleton />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
if (!user) return null;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={refetch}>Refresh</button>
</div>
);
}
Composing Multiple Hooks
Build complex behavior from simple hooks:
// src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// src/hooks/useSearch.ts
import { useState, useMemo } from 'react';
import { useFetch } from './useFetch';
import { useDebounce } from './useDebounce';
interface UseSearchOptions {
debounceMs?: number;
minLength?: number;
}
export function useSearch<T>(
baseUrl: string,
options: UseSearchOptions = {}
) {
const { debounceMs = 300, minLength = 2 } = options;
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, debounceMs);
const shouldFetch = debouncedQuery.length >= minLength;
const url = shouldFetch
? `${baseUrl}?q=${encodeURIComponent(debouncedQuery)}`
: '';
const { data, error, isLoading } = useFetch<T[]>(url, {
enabled: shouldFetch,
});
return {
query,
setQuery,
results: data ?? [],
error,
isLoading: isLoading && shouldFetch,
hasSearched: shouldFetch,
};
}
// Usage
function SearchPage() {
const { query, setQuery, results, isLoading, hasSearched } = useSearch<Product>(
'/api/products/search',
{ debounceMs: 400, minLength: 3 }
);
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
{isLoading && <Spinner />}
{hasSearched && results.length === 0 && !isLoading && (
<p>No results found for "{query}"</p>
)}
<ul>
{results.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
);
}
Hook Guidelines
Do:
- Start with
useprefix - Call hooks at the top level (not inside conditions, loops, or nested functions)
- Return arrays
[value, setValue]for single values (likeuseState) - Return objects
{ value, loading, error }for multiple values - Include cleanup functions in
useEffectwhen needed
Don't:
- Call hooks conditionally
- Return too many values (destructuring becomes unwieldy)
- Forget the dependency array in
useEffect/useCallback/useMemo
Compound Components
Compound components are groups of components that work together implicitly, sharing state through React Context. This pattern creates flexible APIs where the parent controls state and children consume it.
Basic Example: Accordion
// src/components/Accordion/index.tsx
import {
createContext,
useContext,
useState,
ReactNode,
useCallback,
} from 'react';
// Context for sharing state
interface AccordionContextValue {
expandedItems: Set<string>;
toggleItem: (id: string) => void;
allowMultiple: boolean;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordionContext() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion components must be used within an Accordion');
}
return context;
}
// Root component
interface AccordionProps {
children: ReactNode;
allowMultiple?: boolean;
defaultExpanded?: string[];
}
export function Accordion({
children,
allowMultiple = false,
defaultExpanded = [],
}: AccordionProps) {
const [expandedItems, setExpandedItems] = useState(
() => new Set(defaultExpanded)
);
const toggleItem = useCallback(
(id: string) => {
setExpandedItems((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
if (!allowMultiple) {
next.clear();
}
next.add(id);
}
return next;
});
},
[allowMultiple]
);
return (
<AccordionContext.Provider value={{ expandedItems, toggleItem, allowMultiple }}>
<div className="divide-y divide-gray-200">{children}</div>
</AccordionContext.Provider>
);
}
// Item component
interface AccordionItemProps {
id: string;
children: ReactNode;
}
export function AccordionItem({ id, children }: AccordionItemProps) {
const { expandedItems } = useAccordionContext();
const isExpanded = expandedItems.has(id);
return (
<div data-expanded={isExpanded} className="accordion-item">
{children}
</div>
);
}
// Trigger component
interface AccordionTriggerProps {
id: string;
children: ReactNode;
}
export function AccordionTrigger({ id, children }: AccordionTriggerProps) {
const { expandedItems, toggleItem } = useAccordionContext();
const isExpanded = expandedItems.has(id);
return (
<button
onClick={() => toggleItem(id)}
aria-expanded={isExpanded}
className="flex w-full items-center justify-between py-4 text-left"
>
{children}
<ChevronIcon
className={`transform transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
);
}
// Content component
interface AccordionContentProps {
id: string;
children: ReactNode;
}
export function AccordionContent({ id, children }: AccordionContentProps) {
const { expandedItems } = useAccordionContext();
const isExpanded = expandedItems.has(id);
if (!isExpanded) return null;
return (
<div className="pb-4">
{children}
</div>
);
}
// Attach subcomponents for convenient dot notation
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
// Usage
function FAQ() {
return (
<Accordion allowMultiple defaultExpanded={['faq-1']}>
<Accordion.Item id="faq-1">
<Accordion.Trigger id="faq-1">
What payment methods do you accept?
</Accordion.Trigger>
<Accordion.Content id="faq-1">
We accept Visa, MasterCard, American Express, and PayPal.
</Accordion.Content>
</Accordion.Item>
<Accordion.Item id="faq-2">
<Accordion.Trigger id="faq-2">
How long does shipping take?
</Accordion.Trigger>
<Accordion.Content id="faq-2">
Standard shipping takes 5-7 business days. Express shipping
is available for 2-3 day delivery.
</Accordion.Content>
</Accordion.Item>
</Accordion>
);
}
More Complex Example: Select/Dropdown
// src/components/Select/index.tsx
import {
createContext,
useContext,
useState,
useRef,
useEffect,
ReactNode,
KeyboardEvent,
} from 'react';
interface SelectContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
selectedValue: string | null;
selectValue: (value: string) => void;
highlightedIndex: number;
setHighlightedIndex: (index: number) => void;
options: string[];
registerOption: (value: string) => void;
}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext() {
const context = useContext(SelectContext);
if (!context) {
throw new Error('Select components must be used within a Select');
}
return context;
}
// Root component
interface SelectProps {
children: ReactNode;
value?: string;
onChange?: (value: string) => void;
defaultValue?: string;
}
export function Select({
children,
value: controlledValue,
onChange,
defaultValue,
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [internalValue, setInternalValue] = useState(defaultValue ?? null);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [options, setOptions] = useState<string[]>([]);
const isControlled = controlledValue !== undefined;
const selectedValue = isControlled ? controlledValue : internalValue;
const selectValue = (value: string) => {
if (!isControlled) {
setInternalValue(value);
}
onChange?.(value);
setIsOpen(false);
};
const registerOption = (value: string) => {
setOptions((prev) => {
if (prev.includes(value)) return prev;
return [...prev, value];
});
};
return (
<SelectContext.Provider
value={{
isOpen,
setIsOpen,
selectedValue,
selectValue,
highlightedIndex,
setHighlightedIndex,
options,
registerOption,
}}
>
<div className="relative inline-block">{children}</div>
</SelectContext.Provider>
);
}
// Trigger button
interface SelectTriggerProps {
children?: ReactNode;
placeholder?: string;
}
export function SelectTrigger({ children, placeholder = 'Select...' }: SelectTriggerProps) {
const { isOpen, setIsOpen, selectedValue, options, setHighlightedIndex } =
useSelectContext();
const triggerRef = useRef<HTMLButtonElement>(null);
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault();
setIsOpen(!isOpen);
if (!isOpen) {
setHighlightedIndex(0);
}
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setHighlightedIndex(0);
}
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<button
ref={triggerRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
className="flex items-center justify-between min-w-[200px] px-4 py-2 border rounded"
>
{children ?? selectedValue ?? placeholder}
<ChevronIcon className={isOpen ? 'rotate-180' : ''} />
</button>
);
}
// Options container
interface SelectOptionsProps {
children: ReactNode;
}
export function SelectOptions({ children }: SelectOptionsProps) {
const { isOpen, highlightedIndex, setHighlightedIndex, options, selectValue } =
useSelectContext();
const listRef = useRef<HTMLUListElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: globalThis.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(Math.min(highlightedIndex + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(Math.max(highlightedIndex - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0) {
selectValue(options[highlightedIndex]);
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, highlightedIndex, options, selectValue, setHighlightedIndex]);
if (!isOpen) return null;
return (
<ul
ref={listRef}
role="listbox"
className="absolute top-full left-0 w-full mt-1 bg-white border rounded shadow-lg z-50"
>
{children}
</ul>
);
}
// Individual option
interface SelectOptionProps {
value: string;
children: ReactNode;
}
export function SelectOption({ value, children }: SelectOptionProps) {
const {
selectValue,
selectedValue,
highlightedIndex,
setHighlightedIndex,
options,
registerOption,
} = useSelectContext();
useEffect(() => {
registerOption(value);
}, [value, registerOption]);
const index = options.indexOf(value);
const isSelected = selectedValue === value;
const isHighlighted = highlightedIndex === index;
return (
<li
role="option"
aria-selected={isSelected}
onClick={() => selectValue(value)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`
px-4 py-2 cursor-pointer
${isHighlighted ? 'bg-blue-50' : ''}
${isSelected ? 'font-semibold' : ''}
`}
>
{children}
</li>
);
}
// Dot notation exports
Select.Trigger = SelectTrigger;
Select.Options = SelectOptions;
Select.Option = SelectOption;
// Usage
function CountrySelector() {
const [country, setCountry] = useState('');
return (
<Select value={country} onChange={setCountry}>
<Select.Trigger placeholder="Choose a country" />
<Select.Options>
<Select.Option value="us">United States</Select.Option>
<Select.Option value="uk">United Kingdom</Select.Option>
<Select.Option value="ca">Canada</Select.Option>
<Select.Option value="au">Australia</Select.Option>
</Select.Options>
</Select>
);
}
When to Use Compound Components
Good fit:
- Component groups that share implicit state (tabs, accordions, dropdowns)
- APIs where composition matters (
<Menu><Menu.Item/>is clearer than passing arrays) - Components with optional subcomponents
Not ideal:
- Simple components with few props
- When you need to pass data between distant children (consider different patterns)
Container/Presenter Pattern
This pattern separates data concerns from presentation concerns. Containers handle data fetching, state management, and business logic. Presenters (or "dumb" components) only render UI based on props.
Classic Implementation
// src/components/UserList/UserListPresenter.tsx
// Pure presentational component - no data fetching, no complex logic
interface User {
id: string;
name: string;
email: string;
avatar: string;
}
interface UserListPresenterProps {
users: User[];
isLoading: boolean;
error: Error | null;
onUserClick: (userId: string) => void;
onRetry: () => void;
}
export function UserListPresenter({
users,
isLoading,
error,
onUserClick,
onRetry,
}: UserListPresenterProps) {
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse flex items-center space-x-4">
<div className="w-12 h-12 bg-gray-200 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-1/4" />
<div className="h-3 bg-gray-200 rounded w-1/3" />
</div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<p className="text-red-600 mb-4">Failed to load users: {error.message}</p>
<button
onClick={onRetry}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Try Again
</button>
</div>
);
}
if (users.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No users found
</div>
);
}
return (
<ul className="divide-y divide-gray-200">
{users.map((user) => (
<li
key={user.id}
onClick={() => onUserClick(user.id)}
className="flex items-center space-x-4 py-4 cursor-pointer hover:bg-gray-50"
>
<img
src={user.avatar}
alt=""
className="w-12 h-12 rounded-full"
/>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</li>
))}
</ul>
);
}
// src/components/UserList/UserListContainer.tsx
// Container handles all data logic
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFetch } from '../../hooks/useFetch';
import { UserListPresenter } from './UserListPresenter';
export function UserListContainer() {
const navigate = useNavigate();
const { data: users, error, isLoading, refetch } = useFetch<User[]>('/api/users');
const handleUserClick = useCallback((userId: string) => {
navigate(`/users/${userId}`);
}, [navigate]);
return (
<UserListPresenter
users={users ?? []}
isLoading={isLoading}
error={error}
onUserClick={handleUserClick}
onRetry={refetch}
/>
);
}
// src/pages/UsersPage.tsx
import { UserListContainer } from '../components/UserList/UserListContainer';
export function UsersPage() {
return (
<div className="max-w-2xl mx-auto py-8">
<h1 className="text-2xl font-bold mb-6">Users</h1>
<UserListContainer />
</div>
);
}
Modern Hooks Version
With hooks, the separation often happens inside a single component file:
// src/components/UserList.tsx
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useFetch } from '../hooks/useFetch';
// Custom hook encapsulates "container" logic
function useUserList() {
const navigate = useNavigate();
const { data, error, isLoading, refetch } = useFetch<User[]>('/api/users');
const handleUserClick = useCallback((userId: string) => {
navigate(`/users/${userId}`);
}, [navigate]);
return {
users: data ?? [],
error,
isLoading,
refetch,
handleUserClick,
};
}
// Component is primarily presentational
export function UserList() {
const { users, error, isLoading, refetch, handleUserClick } = useUserList();
if (isLoading) return <UserListSkeleton />;
if (error) return <ErrorState error={error} onRetry={refetch} />;
if (users.length === 0) return <EmptyState />;
return (
<ul className="divide-y divide-gray-200">
{users.map((user) => (
<UserListItem
key={user.id}
user={user}
onClick={() => handleUserClick(user.id)}
/>
))}
</ul>
);
}
Testing Benefits
The main win is testability. Presenter components are trivial to test:
// src/components/UserList/UserListPresenter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { UserListPresenter } from './UserListPresenter';
const mockUsers = [
{ id: '1', name: 'Alice', email: '[email protected]', avatar: '/avatars/1.jpg' },
{ id: '2', name: 'Bob', email: '[email protected]', avatar: '/avatars/2.jpg' },
];
describe('UserListPresenter', () => {
const defaultProps = {
users: [],
isLoading: false,
error: null,
onUserClick: vi.fn(),
onRetry: vi.fn(),
};
it('renders loading state', () => {
render(<UserListPresenter {...defaultProps} isLoading={true} />);
expect(screen.getAllByRole('status')).toHaveLength(3); // Skeleton items
});
it('renders error state with retry button', () => {
const error = new Error('Network error');
render(<UserListPresenter {...defaultProps} error={error} />);
expect(screen.getByText(/failed to load users/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
});
it('renders user list and handles clicks', () => {
const onUserClick = vi.fn();
render(
<UserListPresenter {...defaultProps} users={mockUsers} onUserClick={onUserClick} />
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
fireEvent.click(screen.getByText('Alice'));
expect(onUserClick).toHaveBeenCalledWith('1');
});
it('renders empty state', () => {
render(<UserListPresenter {...defaultProps} users={[]} />);
expect(screen.getByText(/no users found/i)).toBeInTheDocument();
});
});
When the Lines Blur
In modern React, strict separation isn't always necessary:
- Small components: A component fetching its own data is fine if it's simple
- Server Components (Next.js/React 19): Data fetching moves to the server, blurring container/presenter
- Co-location: Sometimes keeping data logic near the UI that uses it is clearer
The pattern is most valuable when:
- You need multiple presentational variants (desktop vs. mobile, different themes)
- You want to reuse presentation with different data sources
- You're testing complex UI states
Controlled vs. Uncontrolled Components
This pattern determines who "owns" a component's state—the component itself (uncontrolled) or the parent (controlled).
Uncontrolled: Component Manages State
// Uncontrolled: input manages its own value
function UncontrolledInput() {
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
// Read value imperatively when needed
console.log(inputRef.current?.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="initial" />
<button type="submit">Submit</button>
</form>
);
}
Controlled: Parent Manages State
// Controlled: parent owns the value
function ControlledInput() {
const [value, setValue] = useState('initial');
const handleSubmit = () => {
console.log(value); // Always have current value
};
return (
<form onSubmit={handleSubmit}>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button type="submit">Submit</button>
</form>
);
}
Building Flexible Components with Control Props
The best component APIs support both patterns:
// src/components/Toggle.tsx
import { useState, useCallback } from 'react';
interface ToggleProps {
// Controlled props
checked?: boolean;
onChange?: (checked: boolean) => void;
// Uncontrolled props
defaultChecked?: boolean;
// Common props
disabled?: boolean;
label?: string;
}
export function Toggle({
checked: controlledChecked,
onChange,
defaultChecked = false,
disabled = false,
label,
}: ToggleProps) {
// Track whether we're in controlled mode
const isControlled = controlledChecked !== undefined;
// Internal state for uncontrolled mode
const [internalChecked, setInternalChecked] = useState(defaultChecked);
// Use controlled value if provided, otherwise internal
const checked = isControlled ? controlledChecked : internalChecked;
const handleChange = useCallback(() => {
if (disabled) return;
const newValue = !checked;
// Update internal state if uncontrolled
if (!isControlled) {
setInternalChecked(newValue);
}
// Always call onChange if provided
onChange?.(newValue);
}, [checked, disabled, isControlled, onChange]);
return (
<button
role="switch"
aria-checked={checked}
aria-disabled={disabled}
onClick={handleChange}
className={`
relative w-12 h-6 rounded-full transition-colors
${checked ? 'bg-blue-500' : 'bg-gray-300'}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
>
<span
className={`
absolute top-1 w-4 h-4 bg-white rounded-full transition-transform
${checked ? 'translate-x-6' : 'translate-x-1'}
`}
/>
{label && <span className="sr-only">{label}</span>}
</button>
);
}
// Usage: Uncontrolled with default
function PreferencesForm() {
return (
<form>
<Toggle defaultChecked={true} label="Email notifications" />
{/* Read value with a ref or on submit */}
</form>
);
}
// Usage: Controlled with state
function SettingsPanel() {
const [darkMode, setDarkMode] = useState(false);
return (
<div>
<Toggle
checked={darkMode}
onChange={setDarkMode}
label="Dark mode"
/>
<p>Current mode: {darkMode ? 'Dark' : 'Light'}</p>
</div>
);
}
// Usage: Controlled with external state (form library)
function FormLibraryExample() {
const { register, watch, setValue } = useForm();
const notifications = watch('notifications');
return (
<Toggle
checked={notifications}
onChange={(v) => setValue('notifications', v)}
label="Notifications"
/>
);
}
Warning: Don't Switch Modes
A component should stay either controlled or uncontrolled throughout its lifecycle:
// ❌ BAD: switching from uncontrolled to controlled
function BadExample() {
const [value, setValue] = useState<string | undefined>(undefined);
return (
<input
value={value} // undefined → controlled later = warning
onChange={(e) => setValue(e.target.value)}
/>
);
}
// ✅ GOOD: always controlled
function GoodExample() {
const [value, setValue] = useState('');
return (
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
React will warn you:
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen.
Choosing Between Controlled and Uncontrolled
Use uncontrolled when:
- You only need the value at specific moments (form submission)
- Performance is critical (fewer re-renders)
- Integrating with non-React code
Use controlled when:
- You need to validate/transform input in real-time
- Multiple components need to stay in sync
- You're implementing complex form logic
- You need to programmatically reset or modify values
See Also
- React - Framework fundamentals
- Classical Patterns in TypeScript - Observer and other patterns React builds on
- System Architecture Patterns - Higher-level patterns that complement component patterns
- React Documentation - Official guides on hooks and patterns