AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useReducer vs useState: When to Use Each in React

Last updated:
useState vs useReducer: Decision Guide for React State Management

Master the difference between useState and useReducer. Learn when to use each Hook, with comparison tables, code examples, and real-world patterns for better state management.

# 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

  1. Quick Comparison
  2. Understanding useState
  3. Understanding useReducer
  4. Key Differences: Side-by-Side
  5. When to Use useState
  6. When to Use useReducer
  7. Refactoring: useState to useReducer
  8. Real-World Patterns
  9. 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:

typescript
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>
  );
}
javascript
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:

typescript
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>
  );
}
javascript
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:

typescript
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>
  );
}
javascript
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:

typescript
// Simple, independent values
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [isVisible, setIsVisible] = useState(true);

useReducer is better for:

typescript
// 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:

typescript
// Updates scattered throughout component
setEmail(newEmail);
setIsLoading(true);
setError(null);
// ... later ...
setIsLoading(false);
setError(message);

useReducer:

typescript
// All logic in one reducer function
dispatch({ type: 'LOGIN_START' });
// ... later ...
dispatch({ type: 'LOGIN_ERROR', payload: message });
// Reducer ensures consistent state transitions

# Testability

useState:

typescript
// 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:

typescript
// 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:

  1. 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);
  2. You have 1-3 pieces of state

    typescript
    // Simple component with basic state
       const [count, setCount] = useState(0);
       const [name, setName] = useState('');
  3. State updates are straightforward

    typescript
    // Direct value assignments, no complex logic
       setEmail(newEmail);
       setCount(c => c + 1);
  4. Each piece of state can fail independently

    typescript
    // Input values don't affect each other
       const [firstName, setFirstName] = useState('');
       const [lastName, setLastName] = useState('');
  5. Form inputs with simple validation

    typescript
    const [email, setEmail] = useState('');
       const [password, setPassword] = useState('');
       // Each field is independent

# When to Use useReducer

Use useReducer when:

  1. 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 reducer
  2. You have complex state transitions

    typescript
    // Workflow: idle -> loading -> success/error -> resetting
       // Certain transitions are only valid from certain states
       // The reducer prevents invalid state combinations
  3. The 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 action
  4. You need to test state logic independently

    typescript
    // Reducer functions are pure and easily testable
       const newState = reducer(currentState, action);
  5. State values depend on each other

    typescript
    // If changing one value affects another
       // E.g., changing quantity affects total price
       // The reducer keeps them synchronized
  6. You have 5+ pieces of related state

    typescript
    interface 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
const [data, setData] = useState(null);
const [error, setError] = useState(null);

But if you have loading states too, switch to useReducer:

typescript
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:

typescript
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),
  };
}


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.

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT