![]()
Combining these two hooks allows you to manage complex state logic while maintaining simple local state where appropriate. Here’s a comprehensive guide to effectively using them together.
Basic Patterns
1. Local UI State with Global State
function UserProfile() {
// Local UI state
const [isEditing, setIsEditing] = useState(false);
const [tempName, setTempName] = useState('');
// Global state managed by reducer
const [state, dispatch] = useReducer(userReducer, initialState);
const handleSave = () => {
dispatch({ type: 'UPDATE_NAME', payload: tempName });
setIsEditing(false);
};
return (
<div>
{isEditing ? (
<>
<input
value={tempName}
onChange={(e) => setTempName(e.target.value)}
/>
<button onClick={handleSave}>Save</button>
</>
) : (
<>
<h2>{state.name}</h2>
<button onClick={() => {
setTempName(state.name);
setIsEditing(true);
}}>
Edit
</button>
</>
)}
</div>
);
}
2. Derived State
function ShoppingCart() {
const [cart, dispatch] = useReducer(cartReducer, initialCart);
const [couponCode, setCouponCode] = useState('');
// Derived state from both reducer state and local state
const totalWithDiscount = useMemo(() => {
const subtotal = cart.items.reduce((sum, item) => sum + item.price, 0);
const discount = couponCode === 'SAVE10' ? subtotal * 0.1 : 0;
return subtotal - discount;
}, [cart.items, couponCode]);
return (
<div>
{/* Cart items rendering */}
<input
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
placeholder="Enter coupon code"
/>
<p>Total: ${totalWithDiscount.toFixed(2)}</p>
</div>
);
}
Advanced Patterns
1. State Machines with useReducer
function formReducer(state, action) {
switch (action.type) {
case 'INPUT_CHANGE':
return { ...state, [action.field]: action.value };
case 'SUBMIT_START':
return { ...state, status: 'submitting', error: null };
case 'SUBMIT_SUCCESS':
return { ...state, status: 'success' };
case 'SUBMIT_ERROR':
return { ...state, status: 'error', error: action.error };
default:
return state;
}
}
function RegistrationForm() {
const [formState, dispatch] = useReducer(formReducer, {
email: '',
password: '',
status: 'idle', // 'idle' | 'submitting' | 'success' | 'error'
error: null
});
// Local UI state not relevant to form submission
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await api.register(formState.email, formState.password);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({ type: 'SUBMIT_ERROR', error });
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={formState.email}
onChange={(e) => dispatch({
type: 'INPUT_CHANGE',
field: 'email',
value: e.target.value
})}
/>
<input
type={showPassword ? 'text' : 'password'}
value={formState.password}
onChange={(e) => dispatch({
type: 'INPUT_CHANGE',
field: 'password',
value: e.target.value
})}
/>
<button type="button" onClick={() => setShowPassword(!showPassword)}>
{showPassword ? 'Hide' : 'Show'} Password
</button>
<button
type="submit"
disabled={formState.status === 'submitting'}
>
{formState.status === 'submitting' ? 'Registering...' : 'Register'}
</button>
{formState.error && <p className="error">{formState.error.message}</p>}
</form>
);
}
2. Combining with Context API
const UserContext = createContext();
function UserProvider({ children }) {
// User data managed by reducer
const [state, dispatch] = useReducer(userReducer, initialUserState);
// Local UI state for provider
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadUser = async () => {
setIsLoading(true);
try {
const user = await api.getUser();
dispatch({ type: 'SET_USER', payload: user });
} catch (error) {
dispatch({ type: 'SET_ERROR', payload: error });
} finally {
setIsLoading(false);
}
};
loadUser();
}, []);
const value = useMemo(() => ({
...state,
isLoading,
dispatch
}), [state, isLoading]);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
function useUser() {
const context = useContext(UserContext);
if (!context) throw new Error('useUser must be used within UserProvider');
return context;
}
3. Asynchronous Actions with Local State
function productsReducer(state, action) {
switch (action.type) {
case 'FETCH_SUCCESS':
return { ...state, products: action.payload, error: null };
case 'FETCH_ERROR':
return { ...state, products: [], error: action.payload };
default:
return state;
}
}
function ProductsPage() {
const [state, dispatch] = useReducer(productsReducer, {
products: [],
error: null
});
// Local UI state for pagination
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
useEffect(() => {
const fetchProducts = async () => {
try {
const data = await api.getProducts(page, perPage);
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
fetchProducts();
}, [page, perPage]);
return (
<div>
<div className="pagination">
<button onClick={() => setPage(p => Math.max(1, p - 1))}>
Previous
</button>
<span>Page {page}</span>
<button onClick={() => setPage(p => p + 1)}>
Next
</button>
<select
value={perPage}
onChange={(e) => setPerPage(Number(e.target.value))}
>
{[5, 10, 20, 50].map(num => (
<option key={num} value={num}>{num} per page</option>
))}
</select>
</div>
{state.error ? (
<p className="error">{state.error}</p>
) : (
<ProductList products={state.products} />
)}
</div>
);
}
Performance Optimization
1. Memoizing Reducer Dispatches
function TodoApp() {
const [todos, dispatch] = useReducer(todosReducer, []);
const [filter, setFilter] = useState('all');
// Memoize filtered todos
const filteredTodos = useMemo(() => {
return todos.filter(todo => {
if (filter === 'all') return true;
if (filter === 'active') return !todo.completed;
return todo.completed;
});
}, [todos, filter]);
// Memoize actions to prevent unnecessary re-renders
const actions = useMemo(() => ({
addTodo: text => dispatch({ type: 'ADD_TODO', text }),
toggleTodo: id => dispatch({ type: 'TOGGLE_TODO', id }),
deleteTodo: id => dispatch({ type: 'DELETE_TODO', id })
}), []);
return (
<>
<TodoFilter filter={filter} setFilter={setFilter} />
<TodoList todos={filteredTodos} actions={actions} />
</>
);
}
2. Combining with useCallback
function ComplexForm() {
const [formData, dispatch] = useReducer(formReducer, initialState);
const [isSubmitting, setIsSubmitting] = useState(false);
// Memoize submit handler
const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
await api.submitForm(formData);
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({ type: 'SUBMIT_ERROR', error });
} finally {
setIsSubmitting(false);
}
}, [formData]);
return (
<form>
{/* Form fields */}
<button
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
TypeScript Implementation
1. Typed Reducer with Local State
type Todo = {
id: string;
text: string;
completed: boolean;
};
type TodoAction =
| { type: 'ADD_TODO'; text: string }
| { type: 'TOGGLE_TODO'; id: string }
| { type: 'DELETE_TODO'; id: string };
function todosReducer(state: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'ADD_TODO':
return [...state, { id: Date.now().toString(), text: action.text, completed: false }];
case 'TOGGLE_TODO':
return state.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
);
case 'DELETE_TODO':
return state.filter(todo => todo.id !== action.id);
default:
return state;
}
}
function TodoApp() {
const [todos, dispatch] = useReducer(todosReducer, []);
const [newTodoText, setNewTodoText] = useState('');
const addTodo = () => {
if (newTodoText.trim()) {
dispatch({ type: 'ADD_TODO', text: newTodoText });
setNewTodoText('');
}
};
return (
<div>
<input
type="text"
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="New todo"
/>
<button onClick={addTodo}>Add</button>
<TodoList todos={todos} dispatch={dispatch} />
</div>
);
}
When to Use Each Approach
| useState | useReducer |
|---|---|
| Simple state | Complex state logic |
| Independent values | Related state values |
| No complex transitions | State transitions |
| Local component state | Global or shared state |
| Direct updates | Updates with clear actions |
Best Practices for Combining Them
- Separation of Concerns:
- Use
useReducerfor business logic and data state - Use
useStatefor UI state and local component state
- Colocation:
- Keep state as close to where it’s needed as possible
- Lift state up only when multiple components need it
- Performance:
- Memoize derived state with
useMemo - Memoize callbacks with
useCallbackwhen passing to optimized children
- Type Safety:
- Use TypeScript to define action types and state shapes
- Create helper functions for action creators
- Testing:
- Test reducers in isolation
- Test components with mock state and dispatches
By strategically combining useState and useReducer, you can create React applications with maintainable state management that scales well with complexity while remaining performant.
