Skip to main content

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

SituationRecommendation
Simple boolean/number/stringuseState
Object with multiple fieldsuseReducer
Next state depends on previous stateuseReducer
Want to separate update logic externallyuseReducer
Testing complex business logicuseReducer (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

ItemuseTransitionuseDeferredValue
Where to useWraps state update codeReceives a value and returns a deferred version
Best forWhen you have access to the update functionWhen the value comes as a prop from outside
Pending stateProvides isPendingRequires 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}>