Introduction to Complex State Management
As React applications grow in complexity, managing state becomes increasingly challenging.
Multiple state updates, interdependent state values, and complex state transitions can make
code difficult to understand and maintain when using only the useState Hook.
Real-world analogy: Think of state management like controlling a sophisticated machine.
With useState, you're directly flipping individual switches and turning dials.
With useReducer, you're using a control panel where you issue commands ("actions")
that trigger predefined sequences of switch and dial adjustments according to a central set of rules.
The useReducer Hook provides a more structured approach to state management,
inspired by Redux, that's particularly useful for complex state logic.
useState vs useReducer
Let's compare the two main approaches to state management in React functional components:
| Feature | useState | useReducer |
|---|---|---|
| State Structure | Usually primitive values or simple objects | Complex objects or interrelated data |
| Update Logic | Spread directly throughout component | Centralized in reducer function |
| Update Patterns | Direct state setting | Dispatching actions |
| Predictability | Less structured, more ad-hoc | More structured, predictable |
| Testing | Component tests | Separate pure reducer testing |
| Debugging | More difficult with scattered updates | Easier with actions and centralized logic |
| Learning Curve | Lower | Higher (requires understanding reducers) |
Example Comparison
Using useState
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
const decrement = () => {
setCount(count - 1);
};
const reset = () => {
setCount(0);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
Using useReducer
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(
counterReducer,
{ count: 0 }
);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
}
For this simple counter example, useState is more concise. But as components grow in complexity,
useReducer starts to shine by making state transitions more explicit and organized.
When to Use useReducer
The useReducer Hook is particularly beneficial in these scenarios:
- Complex state objects with multiple sub-values
- When the next state depends on the previous one
- When state transitions involve multiple related state updates
- When you want your state logic to be testable separately from components
- When you need to share state-updating logic between components
- When you have deep component trees where you pass state updaters down multiple levels
If your component state is simple (a few independent values) and the updates are straightforward,
useState is often the better choice for its simplicity.
complex object?} -->|Yes| U[Use useReducer] A -->|No| B{Do state changes
depend on previous
state?} B -->|Yes| C{Are there many
different ways to
update state?} B -->|No| S[Consider useState] C -->|Yes| U C -->|No| D{Do you need to
share update logic?} D -->|Yes| U D -->|No| S style U fill:#bbf,stroke:#33a,stroke-width:2px style S fill:#fbb,stroke:#a33,stroke-width:2px
Understanding useReducer
Basic Syntax
const [state, dispatch] = useReducer(reducer, initialState, init);
- state: The current state value
- dispatch: Function to dispatch actions to update state
- reducer: Pure function that takes state and action, returns new state
- initialState: Starting state value
- init: (Optional) Function to initialize state lazily
The Reducer Function
A reducer function follows a simple signature:
function reducer(state, action) {
// Return new state based on action
}
Reducers should:
- Be pure functions - same inputs always produce same outputs
- Never mutate the existing state, but return a new state object
- Have no side effects (API calls, timers, etc.)
- Contain all the logic for how state should change in response to actions
Actions
Actions are plain JavaScript objects that describe what happened:
// Simple action
{ type: 'INCREMENT' }
// Action with data (payload)
{ type: 'ADD_TODO', payload: { text: 'Learn useReducer', completed: false } }
By convention, actions typically include:
- A
typeproperty (string) describing the action - Optional additional data (often called
payload)
Building a Complete Example
Let's build a more complex example: a shopping cart system that demonstrates the power of useReducer.
import React, { useReducer } from 'react';
// Initial state
const initialState = {
items: [],
total: 0,
isCheckingOut: false,
error: null
};
// Action types (defined as constants to avoid typos)
const ADD_ITEM = 'ADD_ITEM';
const REMOVE_ITEM = 'REMOVE_ITEM';
const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
const CHECKOUT_START = 'CHECKOUT_START';
const CHECKOUT_SUCCESS = 'CHECKOUT_SUCCESS';
const CHECKOUT_FAILURE = 'CHECKOUT_FAILURE';
const CLEAR_CART = 'CLEAR_CART';
// Reducer function
function cartReducer(state, action) {
switch (action.type) {
case ADD_ITEM: {
const newItem = action.payload;
// Check if item already exists in cart
const existingItemIndex = state.items.findIndex(
item => item.id === newItem.id
);
let updatedItems;
if (existingItemIndex >= 0) {
// Item exists, update quantity
updatedItems = [...state.items];
updatedItems[existingItemIndex] = {
...updatedItems[existingItemIndex],
quantity: updatedItems[existingItemIndex].quantity + 1
};
} else {
// Item is new, add to cart with quantity 1
updatedItems = [...state.items, { ...newItem, quantity: 1 }];
}
// Calculate new total
const newTotal = updatedItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return {
...state,
items: updatedItems,
total: newTotal,
error: null
};
}
case REMOVE_ITEM: {
const itemId = action.payload;
// Filter out the item
const updatedItems = state.items.filter(item => item.id !== itemId);
// Calculate new total
const newTotal = updatedItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return {
...state,
items: updatedItems,
total: newTotal
};
}
case UPDATE_QUANTITY: {
const { itemId, quantity } = action.payload;
// Validate quantity
if (quantity < 1) {
return {
...state,
error: "Quantity must be at least 1"
};
}
// Update item quantity
const updatedItems = state.items.map(item =>
item.id === itemId
? { ...item, quantity }
: item
);
// Calculate new total
const newTotal = updatedItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return {
...state,
items: updatedItems,
total: newTotal,
error: null
};
}
case CHECKOUT_START:
return {
...state,
isCheckingOut: true,
error: null
};
case CHECKOUT_SUCCESS:
return {
...initialState // Reset to empty cart
};
case CHECKOUT_FAILURE:
return {
...state,
isCheckingOut: false,
error: action.payload
};
case CLEAR_CART:
return {
...initialState
};
default:
return state;
}
}
// Component using the reducer
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const { items, total, isCheckingOut, error } = state;
// Sample products (in a real app, these would come from an API)
const products = [
{ id: 1, name: 'Product 1', price: 10.99 },
{ id: 2, name: 'Product 2', price: 24.99 },
{ id: 3, name: 'Product 3', price: 5.99 }
];
const handleAddItem = (product) => {
dispatch({ type: ADD_ITEM, payload: product });
};
const handleRemoveItem = (itemId) => {
dispatch({ type: REMOVE_ITEM, payload: itemId });
};
const handleUpdateQuantity = (itemId, quantity) => {
dispatch({
type: UPDATE_QUANTITY,
payload: { itemId, quantity: parseInt(quantity, 10) }
});
};
const handleCheckout = async () => {
// In a real app, this would be an API call
dispatch({ type: CHECKOUT_START });
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Simulate successful checkout
dispatch({ type: CHECKOUT_SUCCESS });
} catch (error) {
dispatch({ type: CHECKOUT_FAILURE, payload: error.message });
}
};
const handleClearCart = () => {
dispatch({ type: CLEAR_CART });
};
return (
<div className="shopping-cart">
<h2>Shopping Cart</h2>
{error && (
<div className="error-message">Error: {error}</div>
)}
<div className="product-list">
<h3>Available Products</h3>
<ul>
{products.map(product => (
<li key={product.id}>
{product.name} - ${product.price.toFixed(2)}
<button onClick={() => handleAddItem(product)}>
Add to Cart
</button>
</li>
))}
</ul>
</div>
<div className="cart-items">
<h3>Cart Items</h3>
{items.length === 0 ? (
<p>Your cart is empty</p>
) : (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} - ${item.price.toFixed(2)} ×
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) => handleUpdateQuantity(item.id, e.target.value)}
style={{ width: '40px' }}
/>
= ${(item.price * item.quantity).toFixed(2)}
<button onClick={() => handleRemoveItem(item.id)}>
Remove
</button>
</li>
))}
</ul>
)}
</div>
<div className="cart-summary">
<p><strong>Total: ${total.toFixed(2)}</strong></p>
<button
onClick={handleCheckout}
disabled={items.length === 0 || isCheckingOut}
>
{isCheckingOut ? 'Processing...' : 'Checkout'}
</button>
<button
onClick={handleClearCart}
disabled={items.length === 0}
>
Clear Cart
</button>
</div>
</div>
);
}
This shopping cart example demonstrates several key benefits of useReducer:
- Handling complex state with multiple properties
- Managing related state updates (items and total)
- Centralizing business logic in the reducer
- Handling different state transitions with clear action types
- Making state updates predictable and testable
Advanced useReducer Patterns
Lazy Initialization
For expensive initial state calculations, you can use lazy initialization by passing
a function as the third argument to useReducer:
function init(initialCount) {
// This could be a complex calculation or fetch from localStorage
return {
count: initialCount,
history: [],
lastUpdated: new Date().toISOString()
};
}
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + 1,
history: [...state.history, { type: 'increment', timestamp: new Date().toISOString() }],
lastUpdated: new Date().toISOString()
};
// Other cases...
default:
return state;
}
}
function Counter({ initialCount }) {
// The init function is only called during the initial render
const [state, dispatch] = useReducer(counterReducer, initialCount, init);
// Component JSX...
}
This is particularly useful when:
- The initial state requires complex computation
- You're retrieving state from localStorage or other storage
- You want to reset state to its initial value later
Multiple Reducers
For very complex applications, you can use multiple useReducer Hooks
to separate concerns:
function UserPage() {
// User profile state
const [userState, userDispatch] = useReducer(userReducer, initialUserState);
// User preferences state
const [prefsState, prefsDispatch] = useReducer(prefsReducer, initialPrefsState);
// Activity log state
const [activityState, activityDispatch] = useReducer(
activityReducer,
initialActivityState
);
// Component JSX using multiple state objects and dispatchers...
}
Combining with useContext for Global State
You can create a global state management solution by combining useReducer
with useContext:
// Create contexts
const StateContext = React.createContext();
const DispatchContext = React.createContext();
// Provider component
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// Custom hooks to use the context
function useAppState() {
const context = useContext(StateContext);
if (context === undefined) {
throw new Error('useAppState must be used within an AppProvider');
}
return context;
}
function useAppDispatch() {
const context = useContext(DispatchContext);
if (context === undefined) {
throw new Error('useAppDispatch must be used within an AppProvider');
}
return context;
}
// Usage in a component
function SomeComponent() {
const state = useAppState();
const dispatch = useAppDispatch();
// Use state and dispatch...
}
// App wrapper
function App() {
return (
<AppProvider>
<MainLayout />
</AppProvider>
);
}
This pattern creates a Redux-like state management system without needing any external libraries.
Optimizing State Updates
Avoiding Deep Nesting
When working with complex state objects, deeply nested structures can make updates verbose and error-prone:
⚠️ Deeply Nested State
// Initial state
const initialState = {
user: {
profile: {
personal: {
name: 'John',
age: 30
},
work: {
title: 'Developer',
company: 'Tech Co'
}
},
preferences: {
theme: 'dark',
notifications: true
}
}
};
// Updating deeply nested property
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_NAME':
return {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
personal: {
...state.user.profile.personal,
name: action.payload
}
}
}
};
// Other cases...
}
}
✅ Flatter State Structure
// Initial state
const initialState = {
userPersonal: {
name: 'John',
age: 30
},
userWork: {
title: 'Developer',
company: 'Tech Co'
},
userPreferences: {
theme: 'dark',
notifications: true
}
};
// Updating property in flatter structure
function reducer(state, action) {
switch (action.type) {
case 'UPDATE_NAME':
return {
...state,
userPersonal: {
...state.userPersonal,
name: action.payload
}
};
// Other cases...
}
}
Using Immer for Immutable Updates
Immer is a library that allows you to write "mutating" code while still preserving immutability under the hood:
import { useReducer } from 'react';
import produce from 'immer';
// Initial state
const initialState = {
user: {
profile: {
personal: {
name: 'John',
age: 30
},
work: {
title: 'Developer',
company: 'Tech Co'
}
},
preferences: {
theme: 'dark',
notifications: true
}
}
};
// Immer-powered reducer
function reducer(state, action) {
return produce(state, draft => {
switch (action.type) {
case 'UPDATE_NAME':
// "Mutate" the draft safely
draft.user.profile.personal.name = action.payload;
break;
case 'INCREMENT_AGE':
draft.user.profile.personal.age += 1;
break;
case 'TOGGLE_THEME':
draft.user.preferences.theme =
draft.user.preferences.theme === 'dark' ? 'light' : 'dark';
break;
// Other cases...
}
});
}
function UserProfile() {
const [state, dispatch] = useReducer(reducer, initialState);
// Component JSX...
}
Immer makes complex state updates much more readable and less error-prone, especially for deeply nested state objects.
Action Creators
As your application grows, you can create action creator functions to standardize and encapsulate action creation logic:
// Action creators
function addTodo(text) {
return {
type: 'ADD_TODO',
payload: {
id: Date.now(),
text,
completed: false
}
};
}
function toggleTodo(id) {
return {
type: 'TOGGLE_TODO',
payload: id
};
}
function deleteTodo(id) {
return {
type: 'DELETE_TODO',
payload: id
};
}
// Component using action creators
function TodoApp() {
const [state, dispatch] = useReducer(todoReducer, initialState);
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
dispatch(addTodo(text));
setText('');
};
return (
<div>
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
/>
<button type="submit">Add Todo</button>
</form>
<ul>
{state.todos.map(todo => (
<li key={todo.id}>
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onClick={() => dispatch(toggleTodo(todo.id))}
>
{todo.text}
</span>
<button onClick={() => dispatch(deleteTodo(todo.id))}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}
Action creators provide several benefits:
- Encapsulate the action structure in one place
- Make action creation reusable across components
- Allow adding logic to action creation (like generating IDs)
- Improve testability
- Make your component code cleaner
Handling Asynchronous Actions
Reducers must be pure functions, so they can't contain asynchronous logic. However, you can handle async operations by dispatching different actions at each stage of the async process:
// Reducer
function dataReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return {
...state,
loading: true,
error: null
};
case 'FETCH_SUCCESS':
return {
...state,
loading: false,
data: action.payload,
error: null
};
case 'FETCH_ERROR':
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
}
// Component using async actions
function DataComponent() {
const [state, dispatch] = useReducer(dataReducer, {
data: null,
loading: false,
error: null
});
const fetchData = async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch('https://api.example.com/data');
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'FETCH_ERROR', payload: error.message });
}
};
useEffect(() => {
fetchData();
}, []);
if (state.loading) return <div>Loading...</div>;
if (state.error) return <div>Error: {state.error}</div>;
if (!state.data) return <div>No data</div>;
return (
<div>
<h2>Data</h2>
<pre>{JSON.stringify(state.data, null, 2)}</pre>
<button onClick={fetchData}>Refresh Data</button>
</div>
);
}
For more complex async patterns, you might want to implement a middleware-like approach similar to Redux Thunk or Redux Saga, but that's beyond the scope of this lecture.
Testing Reducers
One of the major benefits of using reducers is that they are pure functions which makes them easy to test:
// Reducer to test
function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'SET':
return { count: action.payload };
default:
return state;
}
}
// Tests (using Jest)
describe('counterReducer', () => {
// Initial state for tests
const initialState = { count: 0 };
test('should return initial state when no action is provided', () => {
expect(counterReducer(initialState, {})).toEqual(initialState);
});
test('should increment count', () => {
expect(counterReducer(initialState, { type: 'INCREMENT' }))
.toEqual({ count: 1 });
});
test('should decrement count', () => {
expect(counterReducer({ count: 5 }, { type: 'DECREMENT' }))
.toEqual({ count: 4 });
});
test('should set count to specific value', () => {
expect(counterReducer(initialState, { type: 'SET', payload: 10 }))
.toEqual({ count: 10 });
});
test('should handle consecutive actions correctly', () => {
let state = initialState;
state = counterReducer(state, { type: 'INCREMENT' });
state = counterReducer(state, { type: 'INCREMENT' });
state = counterReducer(state, { type: 'DECREMENT' });
expect(state).toEqual({ count: 1 });
});
});
This demonstrates how easy it is to test reducers in isolation, without needing to render components or mock React's internal APIs.
Real-World Application: Form Management
Forms are a classic example where useReducer shines. Let's implement a multi-step
registration form with validation:
import React, { useReducer } from 'react';
// Form state and actions
const initialState = {
step: 1,
formData: {
// Personal details (step 1)
firstName: '',
lastName: '',
email: '',
// Address (step 2)
address: '',
city: '',
state: '',
zipCode: '',
// Account details (step 3)
username: '',
password: '',
confirmPassword: ''
},
errors: {},
isSubmitting: false,
isSubmitted: false
};
// Action types
const UPDATE_FIELD = 'UPDATE_FIELD';
const VALIDATE_STEP = 'VALIDATE_STEP';
const NEXT_STEP = 'NEXT_STEP';
const PREV_STEP = 'PREV_STEP';
const SUBMIT_FORM = 'SUBMIT_FORM';
const SUBMIT_SUCCESS = 'SUBMIT_SUCCESS';
const SUBMIT_ERROR = 'SUBMIT_ERROR';
// Form reducer
function formReducer(state, action) {
switch (action.type) {
case UPDATE_FIELD:
return {
...state,
formData: {
...state.formData,
[action.field]: action.value
},
// Clear error for this field when it's updated
errors: {
...state.errors,
[action.field]: undefined
}
};
case VALIDATE_STEP: {
const errors = {};
const currentStep = state.step;
if (currentStep === 1) {
// Validate step 1 fields
if (!state.formData.firstName.trim()) {
errors.firstName = 'First name is required';
}
if (!state.formData.lastName.trim()) {
errors.lastName = 'Last name is required';
}
if (!state.formData.email.trim()) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(state.formData.email)) {
errors.email = 'Email is invalid';
}
} else if (currentStep === 2) {
// Validate step 2 fields
if (!state.formData.address.trim()) {
errors.address = 'Address is required';
}
if (!state.formData.city.trim()) {
errors.city = 'City is required';
}
if (!state.formData.state.trim()) {
errors.state = 'State is required';
}
if (!state.formData.zipCode.trim()) {
errors.zipCode = 'ZIP code is required';
} else if (!/^\d{5}(-\d{4})?$/.test(state.formData.zipCode)) {
errors.zipCode = 'ZIP code is invalid';
}
} else if (currentStep === 3) {
// Validate step 3 fields
if (!state.formData.username.trim()) {
errors.username = 'Username is required';
} else if (state.formData.username.length < 4) {
errors.username = 'Username must be at least 4 characters';
}
if (!state.formData.password) {
errors.password = 'Password is required';
} else if (state.formData.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (!state.formData.confirmPassword) {
errors.confirmPassword = 'Please confirm your password';
} else if (state.formData.confirmPassword !== state.formData.password) {
errors.confirmPassword = 'Passwords do not match';
}
}
return {
...state,
errors
};
}
case NEXT_STEP: {
// Only proceed if there are no errors
if (Object.keys(state.errors).length === 0) {
return {
...state,
step: state.step + 1
};
}
return state;
}
case PREV_STEP:
return {
...state,
step: Math.max(1, state.step - 1)
};
case SUBMIT_FORM:
return {
...state,
isSubmitting: true
};
case SUBMIT_SUCCESS:
return {
...state,
isSubmitting: false,
isSubmitted: true
};
case SUBMIT_ERROR:
return {
...state,
isSubmitting: false,
errors: {
...state.errors,
form: action.error
}
};
default:
return state;
}
}
// Multi-step registration form component
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (e) => {
const { name, value } = e.target;
dispatch({
type: UPDATE_FIELD,
field: name,
value
});
};
const handleNextStep = () => {
dispatch({ type: VALIDATE_STEP });
// Check if there are errors after validation
if (Object.keys(state.errors).length === 0) {
dispatch({ type: NEXT_STEP });
}
};
const handlePrevStep = () => {
dispatch({ type: PREV_STEP });
};
const handleSubmit = async (e) => {
e.preventDefault();
dispatch({ type: VALIDATE_STEP });
// Only submit if there are no errors
if (Object.keys(state.errors).length === 0) {
dispatch({ type: SUBMIT_FORM });
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Simulate successful submission
dispatch({ type: SUBMIT_SUCCESS });
} catch (error) {
dispatch({
type: SUBMIT_ERROR,
error: error.message || 'Submission failed'
});
}
}
};
// Render different step content based on current step
const renderStepContent = () => {
const { formData, errors } = state;
switch (state.step) {
case 1:
return (
<div className="form-step">
<h2>Personal Information</h2>
<div className="form-group">
<label htmlFor="firstName">First Name:</label>
<input
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className={errors.firstName ? 'error' : ''}
/>
{errors.firstName && (
<div className="error-message">{errors.firstName}</div>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">Last Name:</label>
<input
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className={errors.lastName ? 'error' : ''}
/>
{errors.lastName && (
<div className="error-message">{errors.lastName}</div>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
className={errors.email ? 'error' : ''}
/>
{errors.email && (
<div className="error-message">{errors.email}</div>
)}
</div>
</div>
);
case 2:
return (
<div className="form-step">
<h2>Address Information</h2>
<div className="form-group">
<label htmlFor="address">Address:</label>
<input
id="address"
name="address"
value={formData.address}
onChange={handleChange}
className={errors.address ? 'error' : ''}
/>
{errors.address && (
<div className="error-message">{errors.address}</div>
)}
</div>
<div className="form-group">
<label htmlFor="city">City:</label>
<input
id="city"
name="city"
value={formData.city}
onChange={handleChange}
className={errors.city ? 'error' : ''}
/>
{errors.city && (
<div className="error-message">{errors.city}</div>
)}
</div>
<div className="form-group">
<label htmlFor="state">State:</label>
<select
id="state"
name="state"
value={formData.state}
onChange={handleChange}
className={errors.state ? 'error' : ''}
>
<option value="">Select a state</option>
<option value="CA">California</option>
<option value="NY">New York</option>
<option value="TX">Texas</option>
{/* More states... */}
</select>
{errors.state && (
<div className="error-message">{errors.state}</div>
)}
</div>
<div className="form-group">
<label htmlFor="zipCode">ZIP Code:</label>
<input
id="zipCode"
name="zipCode"
value={formData.zipCode}
onChange={handleChange}
className={errors.zipCode ? 'error' : ''}
/>
{errors.zipCode && (
<div className="error-message">{errors.zipCode}</div>
)}
</div>
</div>
);
case 3:
return (
<div className="form-step">
<h2>Account Information</h2>
<div className="form-group">
<label htmlFor="username">Username:</label>
<input
id="username"
name="username"
value={formData.username}
onChange={handleChange}
className={errors.username ? 'error' : ''}
/>
{errors.username && (
<div className="error-message">{errors.username}</div>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
className={errors.password ? 'error' : ''}
/>
{errors.password && (
<div className="error-message">{errors.password}</div>
)}
</div>
<div className="form-group">
<label htmlFor="confirmPassword">Confirm Password:</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
className={errors.confirmPassword ? 'error' : ''}
/>
{errors.confirmPassword && (
<div className="error-message">{errors.confirmPassword}</div>
)}
</div>
</div>
);
default:
return null;
}
};
// Show success message if form is submitted
if (state.isSubmitted) {
return (
<div className="registration-success">
<h2>Registration Successful!</h2>
<p>Thank you for registering, {state.formData.firstName}!</p>
</div>
);
}
return (
<div className="registration-form">
{/* Progress indicator */}
<div className="form-progress">
{[1, 2, 3].map(step => (
<div
key={step}
className={`progress-step ${state.step === step ? 'active' : ''}
${state.step > step ? 'completed' : ''}`}
>
{step}
</div>
))}
</div>
{/* Form error message */}
{state.errors.form && (
<div className="form-error">{state.errors.form}</div>
)}
{/* Form content */}
<form onSubmit={handleSubmit}>
{renderStepContent()}
{/* Form navigation */}
<div className="form-navigation">
{state.step > 1 && (
<button
type="button"
onClick={handlePrevStep}
disabled={state.isSubmitting}
>
Previous
</button>
)}
{state.step < 3 ? (
<button
type="button"
onClick={handleNextStep}
disabled={state.isSubmitting}
>
Next
</button>
) : (
<button
type="submit"
disabled={state.isSubmitting}
>
{state.isSubmitting ? 'Submitting...' : 'Register'}
</button>
)}
</div>
</form>
</div>
);
}
This complex form example demonstrates many of the strengths of useReducer:
- Managing complex, nested state (form data, errors, current step)
- Handling multiple transitions (validation, navigation, submission)
- Centralizing logic in one place
- Maintaining predictable state throughout the form lifecycle
Practice Exercises
Exercise 1: Todo List with Categories
Create a todo list application with the following features:
- Ability to add, delete, and toggle completion of todos
- Assigning todos to different categories
- Filtering todos by category and completion status
- Sorting todos by different criteria (date, alphabetically)
Use useReducer to manage the application state.
Exercise 2: Game State Management
Implement a simple game (like tic-tac-toe) using useReducer to manage:
- Game board state
- Current player
- Game status (in progress, win, draw)
- Move history for undo functionality
Exercise 3: User Authentication Flow
Create a user authentication flow with the following states:
- Logged out
- Logging in (with loading state)
- Login error
- Logged in
- Session expiring
- Refreshing token
Implement this using useReducer and simulate the API calls with timeouts.
Summary
In this lecture, we've covered:
- The differences between
useStateanduseReducer - When to use
useReduceroveruseState - The basics of creating reducers and dispatching actions
- Advanced patterns like lazy initialization and combining with context
- Optimization techniques for complex state
- Handling asynchronous operations with reducers
- Testing reducer functions
- Real-world applications like shopping carts and complex forms
The useReducer Hook provides a powerful way to manage complex state logic in React applications.
While it introduces more boilerplate than useState, it brings significant benefits in terms of
code organization, predictability, and maintainability as your application grows.
By centralizing your state logic in reducer functions and using a clear action-based approach to state updates, you can build more robust and maintainable React applications that scale well with increasing complexity.