Advanced Hooks — useReducer, useContext, useId, useTransition, useDeferredValue
useReducer
An alternative to useState that manages complex state logic with an action/dispatch pattern. This pattern is inspired by Redux.
import { useReducer } from 'react';
// Define action type constants
const TODO_ACTIONS = {
ADD: 'ADD',
TOGGLE: 'TOGGLE',
DELETE: 'DELETE',
CLEAR_COMPLETED: 'CLEAR_COMPLETED',
};
// Pure function reducer: (state, action) => newState
function todoReducer(state, action) {
switch (action.type) {
case TODO_ACTIONS.ADD:
return [
...state,
{ id: Date.now(), text: action.payload, done: false },
];
case TODO_ACTIONS.TOGGLE:
return state.map(todo =>
todo.id === action.payload
? { ...todo, done: !todo.done }
: todo
);
case TODO_ACTIONS.DELETE:
return state.filter(todo => todo.id !== action.payload);
case TODO_ACTIONS.CLEAR_COMPLETED:
return state.filter(todo => !todo.done);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todoReducer, []);
const [input, setInput] = useState('');
function handleAdd() {
if (!input.trim()) return;
dispatch({ type: TODO_ACTIONS.ADD, payload: input });
setInput('');
}
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<button onClick={handleAdd}>Add</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
onClick={() => dispatch({ type: TODO_ACTIONS.TOGGLE, payload: todo.id })}
>
{todo.text}
</span>
<button onClick={() => dispatch({ type: TODO_ACTIONS.DELETE, payload: todo.id })}>
Delete
</button>
</li>
))}
</ul>
<button onClick={() => dispatch({ type: TODO_ACTIONS.CLEAR_COMPLETED })}>
Clear Completed
</button>
</div>
);
}
useState vs useReducer
| Situation | Recommendation |
|---|---|
| Simple boolean/number/string | useState |
| Object with multiple fields | useReducer |
| Next state depends on previous state | useReducer |
| Want to separate update logic externally | useReducer |
| Testing complex business logic | useReducer (easy to test since it's a pure function) |
useContext
Passes data throughout the component tree without props.
import { createContext, useContext, useState } from 'react';
// 1. Create Context
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
// 2. Provider component
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
function toggleTheme() {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3. Wrap in custom hook (recommended)
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used inside ThemeProvider.');
}
return context;
}
// 4. Consume
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={`header-${theme}`}>
<h1>App Title</h1>
<button onClick={toggleTheme}>
{theme === 'light' ? '🌙 Dark Mode' : '☀️ Light Mode'}
</button>
</header>
);
}
// Add Provider at the app root
function App() {
return (
<ThemeProvider>
<Header />
<main>Content</main>
</ThemeProvider>
);
}
Context Optimization
When a Context value changes, all components subscribed to that Context re-render.
// ❌ Frequently changing and rarely changing values in the same Context cause excessive re-renders
const AppContext = createContext({ user: null, theme: 'light', notifications: [] });
// ✅ Separate Context by concern
const UserContext = createContext(null); // Changes only once at login
const ThemeContext = createContext('light'); // Changes only on theme switch
const NotificationContext = createContext([]); // Changes frequently
useId
Generates consistent unique IDs between client and server. Useful for linking labels to inputs for accessibility (a11y).
import { useId } from 'react';
function FormField({ label, type = 'text', ...rest }) {
const id = useId();
return (
<div>
<label htmlFor={id}>{label}</label>
<input id={id} type={type} {...rest} />
</div>
);
}
// No ID collisions even with multiple instances
function App() {
return (
<form>
<FormField label="Name" type="text" />
<FormField label="Email" type="email" />
<FormField label="Password" type="password" />
</form>
);
}
useTransition
Marks non-urgent state updates to maintain UI responsiveness.
import { useState, useTransition } from 'react';
function SearchPage({ allItems }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(allItems);
const [isPending, startTransition] = useTransition();
function handleSearch(e) {
const value = e.target.value;
setQuery(value); // Urgent update: immediately reflect input value
startTransition(() => {
// Non-urgent update: React processes when available
const result = allItems.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFiltered(result);
});
}
return (
<div>
<input value={query} onChange={handleSearch} placeholder="Search..." />
{isPending && <span>Searching...</span>}
<ul style={{ opacity: isPending ? 0.7 : 1 }}>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
useDeferredValue
Defers the update of a value to prevent expensive rendering from blocking the UI.
import { useState, useDeferredValue, memo } from 'react';
// Slow component (renders thousands of items)
const SlowList = memo(function SlowList({ query }) {
// Artificially slow rendering simulation
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`,
match: `Item ${i}`.includes(query),
}));
return (
<ul>
{items.filter(i => i.match).map(item => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
});
function App() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); // Deferred value
const isStale = query !== deferredQuery; // Whether update is pending
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.7 : 1 }}>
<SlowList query={deferredQuery} />
</div>
</div>
);
}
useTransition vs useDeferredValue
| Item | useTransition | useDeferredValue |
|---|---|---|
| Where to use | Wraps state update code | Receives a value and returns a deferred version |
| Best for | When you have access to the update function | When the value comes as a prop from outside |
| Pending state | Provides isPending | Requires manual comparison |
useImperativeHandle + forwardRef
Allows a parent component to directly call specific methods on a child component.
import { forwardRef, useImperativeHandle, useRef } from 'react';
// Child component: define methods to expose via ref
const VideoPlayer = forwardRef(function VideoPlayer({ src }, ref) {
const videoRef = useRef(null);
// Define methods to expose to parent
useImperativeHandle(ref, () => ({
play() {
videoRef.current.play();
},
pause() {
videoRef.current.pause();
},
seek(time) {
videoRef.current.currentTime = time;
},
get currentTime() {
return videoRef.current.currentTime;
},
}), []);
return <video ref={videoRef} src={src} />;
});
// Parent component
function VideoControls() {
const playerRef = useRef(null);
return (
<div>
<VideoPlayer ref={playerRef} src="/video.mp4" />
<button onClick={() => playerRef.current.play()}>Play</button>
<button onClick={() => playerRef.current.pause()}>Pause</button>
<button onClick={() => playerRef.current.seek(0)}>Back to Start</button>
</div>
);
}
Practical Example: Shopping Cart State Management (useReducer + useContext)
import { createContext, useContext, useReducer } from 'react';
// Cart reducer
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
case 'REMOVE_ITEM':
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
};
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: action.payload.quantity }
: i
).filter(i => i.quantity > 0),
};
case 'CLEAR':
return { ...state, items: [] };
default:
return state;
}
}
const CartContext = createContext(null);
function CartProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, { items: [] });
const total = cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
return (
<CartContext.Provider value={{ cart, dispatch, total, itemCount }}>
{children}
</CartContext.Provider>
);
}
function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error('useCart must be used inside CartProvider.');
return context;
}
// Usage example
function ProductCard({ product }) {
const { dispatch } = useCart();
return (
<div>
<h3>{product.name}</h3>
<p>${product.price.toLocaleString()}</p>
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: product })}>
Add to Cart
</button>
</div>
);
}
function CartSummary() {
const { cart, total, itemCount, dispatch } = useCart();
return (
<div>
<h2>Cart ({itemCount} items)</h2>
{cart.items.map(item => (
<div key={item.id}>
<span>{item.name} x {item.quantity}</span>
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
Remove
</button>
</div>
))}
<strong>Total: ${total.toLocaleString()}</strong>
</div>
);
}
Pro Tips
1. Context + useReducer = Simple Redux
Most small-to-medium apps can get by with Context + useReducer without Zustand or Redux.
2. Misconception about useTransition
Code inside startTransition still runs synchronously. React only lowers the priority of that update — it does not wait for asynchronous API calls.
3. Stabilizing Context Values
// ❌ Creates new object on every render → re-renders all consumers
<MyContext.Provider value={{ user, logout }}>
// ✅ Stabilize with useMemo
const value = useMemo(() => ({ user, logout }), [user, logout]);
<MyContext.Provider value={value}>