useReducer vs useState: When to Use Each in React
One of the most confusing decisions for React developers is choosing between useState and useReducer. Both manage component state, but they suit different scenarios. Pick the wrong one and you'll either have bloated component code or unnecessary complexity. This guide breaks down the real differences and shows you exactly when to reach for each Hook based on actual code patterns you'll encounter in production.
Table of Contents
- Quick Comparison
- Understanding useState
- Understanding useReducer
- Key Differences: Side-by-Side
- When to Use useState
- When to Use useReducer
- Refactoring: useState to useReducer
- Real-World Patterns
- FAQ
Quick Comparison
| Aspect | useState | useReducer |
|---|---|---|
| Best for | Simple values (strings, numbers, booleans) | Complex objects with related properties |
| State updates | Direct (setState) | Through actions (dispatch) |
| Complexity | Simple setup, simple updates | More boilerplate, more control |
| Dependencies | None | Reducer function stays stable |
| Testing | Test via UI interactions | Test reducer function independently |
| Scalability | Gets unwieldy with 5+ pieces of state | Scales well with complex logic |
| Learning curve | Easier | Steeper |
Understanding useState
The Basics
useState is the simplest way to manage component state. You call it with an initial value, and it returns the current state and a function to update it:
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
Multiple Pieces of State
For multiple values, you call useState multiple times:
import { useState } from 'react';
interface FormData {
email: string;
password: string;
isLoading: boolean;
error: string | null;
}
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
// Handle success
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
setIsLoading(false);
}
};
return (
<form onSubmit={handleLogin}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
/>
{error && <p>{error}</p>}
<button disabled={isLoading}>{isLoading ? 'Loading...' : 'Login'}</button>
</form>
);
}
import { useState } from 'react';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const handleLogin = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (!response.ok) throw new Error('Login failed');
} catch (err) {
setError(err.message);
setIsLoading(false);
}
};
return (
<form onSubmit={handleLogin}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p>{error}</p>}
<button disabled={isLoading}>{isLoading ? 'Loading...' : 'Login'}</button>
</form>
);
}
Notice how the state values are loosely related—they're separate concerns forced to live in separate variables. This works fine, but it's easy to miss updating one (like forgetting to clear the error when you retry).
Understanding useReducer
The Concept
useReducer manages state through a reducer function—a pure function that takes the current state and an action, then returns the new state:
import { useReducer } from 'react';
// Define the shape of your state
interface FormState {
email: string;
password: string;
isLoading: boolean;
error: string | null;
}
// Define the shape of actions
type FormAction =
| { type: 'SET_EMAIL'; payload: string }
| { type: 'SET_PASSWORD'; payload: string }
| { type: 'LOGIN_START' }
| { type: 'LOGIN_SUCCESS' }
| { type: 'LOGIN_ERROR'; payload: string }
| { type: 'RESET' };
// The reducer function: (state, action) => newState
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_EMAIL':
return { ...state, email: action.payload };
case 'SET_PASSWORD':
return { ...state, password: action.payload };
case 'LOGIN_START':
return { ...state, isLoading: true, error: null };
case 'LOGIN_SUCCESS':
return { ...state, isLoading: false, email: '', password: '' };
case 'LOGIN_ERROR':
return { ...state, isLoading: false, error: action.payload };
case 'RESET':
return { email: '', password: '', isLoading: false, error: null };
default:
return state;
}
}
export function LoginFormWithReducer() {
const [state, dispatch] = useReducer(formReducer, {
email: '',
password: '',
isLoading: false,
error: null,
});
const handleLogin = async () => {
dispatch({ type: 'LOGIN_START' });
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email: state.email, password: state.password }),
});
if (!response.ok) throw new Error('Login failed');
dispatch({ type: 'LOGIN_SUCCESS' });
} catch (err) {
dispatch({
type: 'LOGIN_ERROR',
payload: err instanceof Error ? err.message : 'Unknown error',
});
}
};
return (
<form onSubmit={handleLogin}>
<input
value={state.email}
onChange={(e) => dispatch({ type: 'SET_EMAIL', payload: e.target.value })}
/>
<input
value={state.password}
onChange={(e) => dispatch({ type: 'SET_PASSWORD', payload: e.target.value })}
/>
{state.error && <p>{state.error}</p>}
<button disabled={state.isLoading}>
{state.isLoading ? 'Loading...' : 'Login'}
</button>
</form>
);
}
import { useReducer } from 'react';
function formReducer(state, action) {
switch (action.type) {
case 'SET_EMAIL':
return { ...state, email: action.payload };
case 'SET_PASSWORD':
return { ...state, password: action.payload };
case 'LOGIN_START':
return { ...state, isLoading: true, error: null };
case 'LOGIN_SUCCESS':
return { ...state, isLoading: false, email: '', password: '' };
case 'LOGIN_ERROR':
return { ...state, isLoading: false, error: action.payload };
case 'RESET':
return { email: '', password: '', isLoading: false, error: null };
default:
return state;
}
}
export function LoginFormWithReducer() {
const [state, dispatch] = useReducer(formReducer, {
email: '',
password: '',
isLoading: false,
error: null,
});
const handleLogin = async () => {
dispatch({ type: 'LOGIN_START' });
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email: state.email, password: state.password }),
});
if (!response.ok) throw new Error('Login failed');
dispatch({ type: 'LOGIN_SUCCESS' });
} catch (err) {
dispatch({
type: 'LOGIN_ERROR',
payload: err.message,
});
}
};
return (
<form onSubmit={handleLogin}>
<input value={state.email} onChange={(e) => dispatch({ type: 'SET_EMAIL', payload: e.target.value })} />
<input value={state.password} onChange={(e) => dispatch({ type: 'SET_PASSWORD', payload: e.target.value })} />
{state.error && <p>{state.error}</p>}
<button disabled={state.isLoading}>
{state.isLoading ? 'Loading...' : 'Login'}
</button>
</form>
);
}
Notice the difference: with useReducer, all state updates go through one place (the reducer function), making it easy to ensure consistency.
Key Differences: Side-by-Side
State Complexity
useState is better for:
// Simple, independent values
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [isVisible, setIsVisible] = useState(true);
useReducer is better for:
// Related state that changes together
interface CartState {
items: Item[];
totalPrice: number;
discountCode: string | null;
appliedDiscount: number;
}
const [cart, dispatch] = useReducer(cartReducer, initialCart);
Update Logic
useState:
// Updates scattered throughout component
setEmail(newEmail);
setIsLoading(true);
setError(null);
// ... later ...
setIsLoading(false);
setError(message);
useReducer:
// All logic in one reducer function
dispatch({ type: 'LOGIN_START' });
// ... later ...
dispatch({ type: 'LOGIN_ERROR', payload: message });
// Reducer ensures consistent state transitions
Testability
useState:
// Test by rendering component and simulating UI interactions
it('should show error when login fails', async () => {
render(<LoginForm />);
userEvent.type(screen.getByPlaceholderText('Email'), 'test@example.com');
userEvent.click(screen.getByText('Login'));
await screen.findByText('Login failed');
});
useReducer:
// Test reducer function directly
it('should set error on LOGIN_ERROR action', () => {
const state = formReducer(
{ email: '', password: '', isLoading: true, error: null },
{ type: 'LOGIN_ERROR', payload: 'Invalid credentials' }
);
expect(state.error).toBe('Invalid credentials');
expect(state.isLoading).toBe(false);
});
The reducer approach lets you test state transitions without rendering components.
When to Use useState
Use useState when:
State is truly independent
typescript// Each toggle is its own concern const [showMenu, setShowMenu] = useState(false); const [showModal, setShowModal] = useState(false); const [showSidebar, setShowSidebar] = useState(false);You have 1-3 pieces of state
typescript// Simple component with basic state const [count, setCount] = useState(0); const [name, setName] = useState('');State updates are straightforward
typescript// Direct value assignments, no complex logic setEmail(newEmail); setCount(c => c + 1);Each piece of state can fail independently
typescript// Input values don't affect each other const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState('');Form inputs with simple validation
typescriptconst [email, setEmail] = useState(''); const [password, setPassword] = useState(''); // Each field is independent
When to Use useReducer
Use useReducer when:
Multiple state values are tightly coupled
typescript// These always change together interface FetchState { data: Items[] | null; isLoading: boolean; error: string | null; } // When fetching starts: isLoading=true, error=null // When done: isLoading=false, data=items // This logic is enforced in the reducerYou have complex state transitions
typescript// Workflow: idle -> loading -> success/error -> resetting // Certain transitions are only valid from certain states // The reducer prevents invalid state combinationsThe same action triggers multiple state updates
typescript// Instead of: // setIsLoading(false); // setError(null); // setData(items); // dispatch({ type: 'FETCH_SUCCESS', payload: items }); // Reducer handles all updates in one actionYou need to test state logic independently
typescript// Reducer functions are pure and easily testable const newState = reducer(currentState, action);State values depend on each other
typescript// If changing one value affects another // E.g., changing quantity affects total price // The reducer keeps them synchronizedYou have 5+ pieces of related state
typescriptinterface ShoppingCartState { items: Item[]; totalItems: number; totalPrice: number; appliedDiscount: Discount | null; discountedPrice: number; isCheckingOut: boolean; error: string | null; } // This is getting complex enough that useState becomes unwieldy
Refactoring: useState to useReducer
Let's refactor a real example. Here's a complex form with useState:
import { useState } from 'react';
interface FormData {
firstName: string;
lastName: string;
email: string;
country: string;
agreeToTerms: boolean;
isSubmitting: boolean;
submitError: string | null;
}
export function ComplexForm() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [country, setCountry] = useState('');
const [agreeToTerms, setAgreeToTerms] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setSubmitError(null);
try {
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify({
firstName,
lastName,
email,
country,
agreeToTerms,
}),
});
// Success: clear all fields
setFirstName('');
setLastName('');
setEmail('');
setCountry('');
setAgreeToTerms(false);
setIsSubmitting(false);
} catch (error) {
setSubmitError(error instanceof Error ? error.message : 'Unknown error');
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<input value={lastName} onChange={(e) => setLastName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<select value={country} onChange={(e) => setCountry(e.target.value)}>
<option>Select...</option>
</select>
<label>
<input
type="checkbox"
checked={agreeToTerms}
onChange={(e) => setAgreeToTerms(e.target.checked)}
/>
I agree
</label>
{submitError && <p>{submitError}</p>}
<button disabled={isSubmitting}>{isSubmitting ? 'Submitting...' : 'Submit'}</button>
</form>
);
}
This is getting unwieldy—7 separate state variables! Here's the refactored version with useReducer:
import { useReducer } from 'react';
interface FormState {
firstName: string;
lastName: string;
email: string;
country: string;
agreeToTerms: boolean;
isSubmitting: boolean;
submitError: string | null;
}
type FormAction =
| { type: 'SET_FIELD'; field: keyof Omit<FormState, 'isSubmitting' | 'submitError'>; value: string | boolean }
| { type: 'SUBMIT_START' }
| { type: 'SUBMIT_ERROR'; payload: string }
| { type: 'SUBMIT_SUCCESS' }
| { type: 'RESET' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SUBMIT_START':
return { ...state, isSubmitting: true, submitError: null };
case 'SUBMIT_SUCCESS':
return {
firstName: '',
lastName: '',
email: '',
country: '',
agreeToTerms: false,
isSubmitting: false,
submitError: null,
};
case 'SUBMIT_ERROR':
return { ...state, isSubmitting: false, submitError: action.payload };
case 'RESET':
return {
firstName: '',
lastName: '',
email: '',
country: '',
agreeToTerms: false,
isSubmitting: false,
submitError: null,
};
default:
return state;
}
}
export function ComplexForm() {
const [state, dispatch] = useReducer(formReducer, {
firstName: '',
lastName: '',
email: '',
country: '',
agreeToTerms: false,
isSubmitting: false,
submitError: null,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
dispatch({ type: 'SUBMIT_START' });
try {
await fetch('/api/register', {
method: 'POST',
body: JSON.stringify({
firstName: state.firstName,
lastName: state.lastName,
email: state.email,
country: state.country,
agreeToTerms: state.agreeToTerms,
}),
});
dispatch({ type: 'SUBMIT_SUCCESS' });
} catch (error) {
dispatch({
type: 'SUBMIT_ERROR',
payload: error instanceof Error ? error.message : 'Unknown error',
});
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={state.firstName}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'firstName', value: e.target.value })}
/>
<input
value={state.lastName}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'lastName', value: e.target.value })}
/>
<input
value={state.email}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'email', value: e.target.value })}
/>
<select
value={state.country}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'country', value: e.target.value })}
>
<option>Select...</option>
</select>
<label>
<input
type="checkbox"
checked={state.agreeToTerms}
onChange={(e) => dispatch({ type: 'SET_FIELD', field: 'agreeToTerms', value: e.target.checked })}
/>
I agree
</label>
{state.submitError && <p>{state.submitError}</p>}
<button disabled={state.isSubmitting}>
{state.isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
Improvements with useReducer:
- All state transitions in one place (the reducer)
- Consistent state management (SUBMIT_SUCCESS always resets all fields)
- Easier to add new features (add a new action type)
- Reducer is testable independently
Real-World Patterns
Pattern 1: Data Fetching with useReducer
This is where useReducer really shines. Managing loading, data, and error states together:
import { useReducer, useEffect, useCallback } from 'react';
interface FetchState<T> {
data: T | null;
isLoading: boolean;
error: string | null;
}
type FetchAction<T> =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: T }
| { type: 'FETCH_ERROR'; payload: string }
| { type: 'RETRY' };
function fetchReducer<T>(state: FetchState<T>, action: FetchAction<T>): FetchState<T> {
switch (action.type) {
case 'FETCH_START':
return { ...state, isLoading: true, error: null };
case 'FETCH_SUCCESS':
return { data: action.payload, isLoading: false, error: null };
case 'FETCH_ERROR':
return { ...state, isLoading: false, error: action.payload };
case 'RETRY':
return { ...state, isLoading: true, error: null };
default:
return state;
}
}
export function UserProfile() {
const [state, dispatch] = useReducer(fetchReducer<User>, {
data: null,
isLoading: true,
error: null,
});
const fetchUser = useCallback(async () => {
dispatch({ type: 'FETCH_START' });
try {
const response = await fetch('/api/user');
const user = await response.json();
dispatch({ type: 'FETCH_SUCCESS', payload: user });
} catch (error) {
dispatch({
type: 'FETCH_ERROR',
payload: error instanceof Error ? error.message : 'Failed to fetch',
});
}
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
if (state.isLoading) return <div>Loading...</div>;
if (state.error) return <div>Error: {state.error}</div>;
return <div>{state.data?.name}</div>;
}
Pattern 2: Undo/Redo with useReducer
Implementing undo/redo is much simpler with a reducer:
interface EditorState {
content: string;
history: string[];
historyIndex: number;
}
type EditorAction =
| { type: 'EDIT'; payload: string }
| { type: 'UNDO' }
| { type: 'REDO' };
function editorReducer(state: EditorState, action: EditorAction): EditorState {
switch (action.type) {
case 'EDIT':
// Truncate future history when editing
return {
content: action.payload,
history: [...state.history.slice(0, state.historyIndex + 1), action.payload],
historyIndex: state.historyIndex + 1,
};
case 'UNDO':
const undoIndex = Math.max(0, state.historyIndex - 1);
return {
...state,
content: state.history[undoIndex],
historyIndex: undoIndex,
};
case 'REDO':
const redoIndex = Math.min(state.history.length - 1, state.historyIndex + 1);
return {
...state,
content: state.history[redoIndex],
historyIndex: redoIndex,
};
default:
return state;
}
}
export function Editor() {
const [state, dispatch] = useReducer(editorReducer, {
content: '',
history: [''],
historyIndex: 0,
});
return (
<div>
<textarea
value={state.content}
onChange={(e) => dispatch({ type: 'EDIT', payload: e.target.value })}
/>
<button onClick={() => dispatch({ type: 'UNDO' })}>Undo</button>
<button onClick={() => dispatch({ type: 'REDO' })}>Redo</button>
</div>
);
}
FAQ
Q: Is useState always simpler to understand?
A: For beginners, yes. But once state gets complex, useReducer becomes easier to understand because all logic is in one place. You're not hunting through the component looking for different state updates.
Q: Can I mix useState and useReducer in the same component?
A: Yes, absolutely. Use useState for simple independent state and useReducer for complex related state. For example, use useState for a loading toast notification and useReducer for form state.
Q: Should I always use useReducer for API calls?
A: Not always. For a simple fetch, useState is fine:
const [data, setData] = useState(null);
const [error, setError] = useState(null);
But if you have loading states too, switch to useReducer:
const [state, dispatch] = useReducer(reducer, { data: null, error: null, isLoading: false });
Q: How do I choose between useReducer and state management libraries (Redux, Zustand)?
A:
- useReducer: Single component or small feature
- Context + useReducer: App-wide state shared across components
- Redux/Zustand: Complex multi-feature apps with lots of derived state, middleware needs, or time-travel debugging
Q: Is useReducer more performant than useState?
A: Not inherently, but it can be. Since the dispatch function is stable, it's easier to optimize with useCallback without creating dependency issues. With useState, you might accidentally create new functions on every render, causing performance regressions in memoized children.
Q: What if my reducer function gets too large?
A: Break it into smaller reducer functions and compose them:
function authReducer(state, action) { /* ... */ }
function uiReducer(state, action) { /* ... */ }
// Combine them
function appReducer(state, action) {
return {
auth: authReducer(state.auth, action),
ui: uiReducer(state.ui, action),
};
}
Related Articles
- useState Hook: Managing Component State
- useContext: Sharing State Across Components
- Managing Complex State in React
- Building Custom Hooks: Reuse Effect Logic
Questions? What patterns do you use in your projects? Have you run into situations where you picked the wrong Hook and had to refactor? Share your experiences in the comments—I'd love to hear about edge cases where one Hook clearly won over the other.
Google AdSense Placeholder
CONTENT SLOT