useReducer
useReducer in React explains structured state transitions with actions and reducers, when to prefer it over useState, purity, and immutable updates.
useReducer is a React Hook for managing state with more structured update logic. It is especially useful when state transitions are complex, when multiple state values change together, or when the next state depends on the previous state.
Instead of updating state directly with several setter calls, useReducer lets you describe what happened through an action and centralize how state changes inside a reducer function.
Why useReducer?
For simple state, useState is usually the best choice.
const [count, setCount] = useState(0);function increment() {setCount(count + 1);}
As state logic becomes more complicated, direct setters can become harder to reason about:
setLoading(true);setError(null);setData(null);
You may have several related state values and many possible transitions.
With useReducer, those transitions can be modeled explicitly:
dispatch({ type: "FETCH_START" });
The reducer decides what FETCH_START means:
function reducer(state, action) {switch (action.type) {case "FETCH_START":return {...state,loading: true,error: null,};default:return state;}}
This creates a clear separation:
- State — what the application currently knows.
- Action — what happened.
- Reducer — how state should change.
- Dispatch — sends an action to the reducer.
useReducer Syntax
The basic syntax is:
const [state, dispatch] = useReducer(reducer, initialState);
Import it from React:
import { useReducer } from "react";
A complete example:
import { useReducer } from "react";function reducer(state, action) {switch (action.type) {case "increment":return {count: state.count + 1,};case "decrement":return {count: state.count - 1,};default:return state;}}function Counter() {const [state, dispatch] = useReducer(reducer, { count: 0 });return (<div><p>Count: {state.count}</p><button onClick={() => dispatch({ type: "increment" })}>+</button><button onClick={() => dispatch({ type: "decrement" })}>-</button></div>);}export default Counter;
The useReducer Mental Model
Think of useReducer as a small state-management system inside a component.
User interaction↓dispatch()↓Action↓Reducer↓New state↓React renders↓Updated UI
For example:
User clicks "+"↓dispatch({ type: "increment" })↓reducer(state, action)↓count + 1↓React renders again
The reducer does not directly update the screen. It calculates the next state, and React uses that state during rendering.
Understanding the Four Main Pieces
1. State
State represents the current data.
const initialState = {count: 0,status: "idle",};
2. Action
An action describes what happened.
{type: "increment"}
Actions can also contain additional data.
{type: "incrementBy",amount: 5}
The additional data is commonly stored in a payload:
{type: "incrementBy",payload: 5}
There is no requirement that every action use payload, but a consistent convention can make larger reducers easier to understand.
3. Reducer
A reducer is a function that receives the current state and an action and returns the next state.
function reducer(state, action) {switch (action.type) {case "increment":return {...state,count: state.count + 1,};default:return state;}}
The general shape is:
function reducer(state, action) {// calculate next statereturn nextState;}
A reducer should be a pure function.
That means it should:
- use its inputs to calculate the result
- avoid modifying the existing state
- avoid side effects
- return the next state
4. Dispatch
dispatch sends an action to the reducer.
dispatch({ type: "increment" });
With data:
dispatch({type: "incrementBy",payload: 5,});
React then calls the reducer with the current state and the dispatched action.
Conceptually:
const nextState = reducer(state, action);
You normally do not call the reducer yourself.
A Complete Example
Let's build a counter with multiple actions.
import { useReducer } from "react";const initialState = {count: 0,};function reducer(state, action) {switch (action.type) {case "increment":return {...state,count: state.count + 1,};case "decrement":return {...state,count: state.count - 1,};case "incrementBy":return {...state,count: state.count + action.payload,};case "reset":return initialState;default:return state;}}function Counter() {const [state, dispatch] = useReducer(reducer, initialState);return (<div><h2>{state.count}</h2><button onClick={() => dispatch({ type: "increment" })}>Increment</button><button onClick={() => dispatch({ type: "decrement" })}>Decrement</button><buttononClick={() =>dispatch({type: "incrementBy",payload: 5,})}>Add 5</button><button onClick={() => dispatch({ type: "reset" })}>Reset</button></div>);}export default Counter;
The component describes what happened:
dispatch({ type: "increment" });
The reducer describes how the state changes:
case "increment":return {...state,count: state.count + 1,};
This separation becomes valuable as applications grow.
useState vs useReducer
Both Hooks manage state, but they encourage different styles.
useState
useState is straightforward:
const [count, setCount] = useState(0);setCount(count + 1);
Use it when state updates are simple and independent.
useReducer
useReducer centralizes transitions:
dispatch({ type: "increment" });
Use it when state transitions are more complex or closely related.
General guideline
Simple state↓useStateComplex related state transitions↓useReducer
Do not switch to useReducer simply because it looks more advanced. Complexity should justify the abstraction.
Managing Multiple Related Values
Suppose a form has several related states:
const [name, setName] = useState("");const [email, setEmail] = useState("");const [loading, setLoading] = useState(false);const [error, setError] = useState(null);
As the number of transitions increases, it can become difficult to understand which updates belong together.
A reducer can model the state as one object:
const initialState = {name: "",email: "",loading: false,error: null,};
Actions can represent meaningful transitions:
dispatch({type: "nameChanged",payload: "Asha",});
dispatch({type: "submitStarted",});
dispatch({type: "submitSucceeded",});
dispatch({type: "submitFailed",payload: "Unable to save",});
The reducer becomes the central definition of those transitions.
A Form Example
import { useReducer } from "react";const initialState = {name: "",email: "",status: "idle",error: null,};function reducer(state, action) {switch (action.type) {case "nameChanged":return {...state,name: action.payload,};case "emailChanged":return {...state,email: action.payload,};case "submitStarted":return {...state,status: "submitting",error: null,};case "submitSucceeded":return {...state,status: "success",error: null,};case "submitFailed":return {...state,status: "error",error: action.payload,};default:return state;}}function RegistrationForm() {const [state, dispatch] = useReducer(reducer, initialState);function handleSubmit(event) {event.preventDefault();dispatch({ type: "submitStarted" });// Perform submission work here.}return (<form onSubmit={handleSubmit}><inputvalue={state.name}onChange={(event) =>dispatch({type: "nameChanged",payload: event.target.value,})}placeholder="Name"/><inputvalue={state.email}onChange={(event) =>dispatch({type: "emailChanged",payload: event.target.value,})}placeholder="Email"/><button type="submit" disabled={state.status === "submitting"}>Submit</button>{state.status === "error" && <p>{state.error}</p>}{state.status === "success" && <p>Registration successful!</p>}</form>);}export default RegistrationForm;
Notice that the reducer does not perform the submission itself.
It only describes state transitions.
Reducers Should Be Pure
A reducer should calculate state, not perform side effects.
Good:
function reducer(state, action) {switch (action.type) {case "increment":return {...state,count: state.count + 1,};default:return state;}}
Avoid:
function reducer(state, action) {fetch("/api/data");return state;}
Also avoid putting timers, subscriptions, DOM manipulation, or other side effects inside the reducer.
Use event handlers and Effects for those responsibilities.
Do Not Mutate State
Reducers must return new state rather than directly changing the existing state.
Avoid:
function reducer(state, action) {state.count += 1;return state;}
Prefer:
function reducer(state, action) {return {...state,count: state.count + 1,};}
For arrays:
Avoid:
state.items.push(action.payload);return state;
Prefer:
return {...state,items: [...state.items, action.payload],};
For objects:
return {...state,user: {...state.user,name: action.payload,},};
Immutability helps React correctly detect state changes and keeps reducer behavior predictable.
Action Design
Good actions describe meaningful events.
For example:
dispatch({type: "todoAdded",payload: {id: 1,text: "Learn React",},});
Other examples:
dispatch({ type: "todoRemoved", payload: 1 });
dispatch({ type: "todoCompleted", payload: 1 });
dispatch({ type: "todosCleared" });
Prefer action names that describe what happened rather than low-level implementation instructions.
For example:
{ type: "formSubmitted" }
is generally more meaningful than:
{ type: "setLoadingTrue" }
The reducer can decide that formSubmitted should set loading to true.
Action Payloads
Payloads carry information required to perform a transition.
dispatch({type: "quantityChanged",payload: 3,});
The reducer can use it:
case "quantityChanged":return {...state,quantity: action.payload,};
For more structured actions:
dispatch({type: "productAdded",payload: {id: product.id,name: product.name,quantity: 1,},});
Keep actions understandable and avoid putting unrelated information into them.
Lazy Initialization
useReducer can receive a third argument for lazy initialization:
const [state, dispatch] = useReducer(reducer,initialArgument,init);
Example:
function init(initialCount) {return {count: initialCount,};}const [state, dispatch] = useReducer(reducer,10,init);
Here, init calculates the initial state.
This is useful when creating the initial state requires computation or when you want initialization logic separated from the reducer.
Resetting Reducer State
A reducer can reset state using an action:
case "reset":return initialState;
Example:
dispatch({ type: "reset" });
If the initial state depends on an argument, an initialization function can make resetting more reusable:
function init(initialCount) {return {count: initialCount,};}
Then:
case "reset":return init(action.payload);
useReducer and useEffect
Reducers should handle state transitions.
Effects should handle synchronization with external systems.
For example:
User action↓dispatch()↓reducer↓state changes↓render↓useEffect↓external system
Suppose a search screen has:
const [state, dispatch] = useReducer(reducer, initialState);
An event can dispatch:
dispatch({type: "searchChanged",payload: value,});
The reducer updates the state.
An Effect can then synchronize the search state with an external API:
useEffect(() => {// synchronize with external system}, [state.query]);
The responsibilities remain separated:
- reducer → state transition
- Effect → external synchronization
useReducer with Context
useReducer is often combined with React Context when multiple components need access to the same state and dispatch function.
A simplified pattern looks like this:
const [state, dispatch] = useReducer(reducer, initialState);return (<AppContext.Provider value={{ state, dispatch }}>{children}</AppContext.Provider>);
Child components can consume the context:
const { state, dispatch } = useContext(AppContext);
Then dispatch actions:
dispatch({type: "addItem",payload: item,});
This pattern can provide centralized state management without immediately introducing a dedicated state-management library.
Example: Shopping Cart Reducer
A reducer is a natural fit for cart operations.
const initialState = {items: [],};function reducer(state, action) {switch (action.type) {case "addItem": {const existingItem = state.items.find((item) => item.id === action.payload.id);if (existingItem) {return {...state,items: state.items.map((item) =>item.id === action.payload.id? {...item,quantity: item.quantity + 1,}: item),};}return {...state,items: [...state.items,{...action.payload,quantity: 1,},],};}case "removeItem":return {...state,items: state.items.filter((item) => item.id !== action.payload),};case "clearCart":return {...state,items: [],};default:return state;}}
The UI only needs to dispatch meaningful actions:
dispatch({type: "addItem",payload: product,});
dispatch({type: "removeItem",payload: productId,});
dispatch({type: "clearCart",});
The reducer contains the transition rules.
Reducer Organization
For small components, keeping the reducer in the same file is often convenient:
Counter.jsx
For larger features, separating the reducer can improve organization:
features/└── cart/├── Cart.jsx├── cartReducer.js└── cartContext.js
Example:
// cartReducer.jsexport const initialState = {items: [],};export function cartReducer(state, action) {switch (action.type) {// transitionsdefault:return state;}}
Then import it:
import {cartReducer,initialState,} from "./cartReducer";
Keep organization proportional to application complexity.
Common Mistakes
1. Using useReducer for Every Piece of State
This adds unnecessary complexity.
If you only need:
const [isOpen, setIsOpen] = useState(false);
a reducer is usually unnecessary.
2. Mutating State
Avoid:
state.items.push(item);return state;
Use immutable updates:
return {...state,items: [...state.items, item],};
3. Performing Side Effects in Reducers
Avoid:
function reducer(state, action) {localStorage.setItem("data", JSON.stringify(state));return state;}
Keep side effects outside reducers.
4. Forgetting the Default Case
A reducer should safely handle unexpected actions:
default:return state;
5. Returning the Wrong State Shape
If your initial state is:
{count: 0,loading: false}
do not accidentally return:
{count: 1}
unless removing loading is intentional.
Prefer:
{...state,count: 1}
6. Making Actions Too Low-Level
Avoid creating actions that expose every implementation detail:
{ type: "setLoadingTrue" }{ type: "setLoadingFalse" }{ type: "setErrorNull" }
Prefer meaningful events:
{ type: "requestStarted" }{ type: "requestSucceeded" }{ type: "requestFailed", payload: error }
When Should You Use useReducer?
useReducer is a strong choice when:
- multiple state values change together
- state transitions are numerous
- the next state depends on the previous state
- the update logic is complex
- several events can change the same state
- you want state transitions centralized
- action-based state updates make the feature easier to test
- the reducer may be shared with Context-based state management
Examples include:
- shopping carts
- multi-step forms
- complex forms
- authentication state
- filters and sorting
- data-loading state machines
- editors
- dashboards
- workflow interfaces
When Should You Prefer useState?
Prefer useState when:
- state is simple
- updates are easy to understand
- there are only a few transitions
- state values are independent
- a reducer would add more code than value
Example:
const [isMenuOpen, setIsMenuOpen] = useState(false);
There is no need to introduce a reducer just to toggle a Boolean.
useReducer vs useState: Quick Comparison
| Feature | useState | useReducer |
|---|---|---|
| Simple state | Excellent | Usually unnecessary |
| Complex transitions | Can become verbose | Excellent |
| Multiple related values | Sometimes | Often a good fit |
| Action-based updates | No | Yes |
| Centralized transition logic | Limited | Yes |
| Pure reducer function | No separate reducer | Yes |
| Easy local state | Excellent | Good |
| Context integration | Possible | Common pattern |
| Testing transition logic | Possible | Reducer can be tested directly |
Debugging useReducer
When a reducer behaves unexpectedly, inspect the complete transition:
Previous State↓Action↓Reducer↓Next State
For development, you can temporarily log:
function reducer(state, action) {console.log("State:", state);console.log("Action:", action);switch (action.type) {// ...default:return state;}}
You can also test a reducer independently because it is a normal function.
const state = {count: 0,};const nextState = reducer(state, {type: "increment",});console.log(nextState);
A reducer's predictability is one of its major advantages.
Testing Reducers
Because reducers are pure functions, they are easy to test.
For example:
const initialState = {count: 0,};expect(reducer(initialState, { type: "increment" })).toEqual({count: 1,});
Another test:
expect(reducer({ count: 5 },{type: "incrementBy",payload: 3,})).toEqual({count: 8,});
Testing transition logic independently can be particularly useful in larger applications.
useReducer and State Machines
Some complex features naturally behave like a state machine.
For example, a request might have states:
idle↓loading↓success
or:
idle↓loading├──→ success└──→ error
A reducer can model those transitions:
function reducer(state, action) {switch (action.type) {case "requestStarted":return {status: "loading",data: null,error: null,};case "requestSucceeded":return {status: "success",data: action.payload,error: null,};case "requestFailed":return {status: "error",data: null,error: action.payload,};default:return state;}}
This makes allowed transitions easier to see and reason about.
For very sophisticated state machines, specialized state-management tools may provide additional capabilities, but useReducer is often enough for component-level workflows.
Best Practices
1. Keep Reducers Pure
Reducers should calculate state and avoid side effects.
2. Keep Actions Meaningful
Describe events rather than implementation details.
3. Preserve Immutability
Create new objects and arrays instead of mutating existing state.
4. Keep State Minimal
Do not store values that can be derived during rendering.
5. Use useState for Simple State
Do not introduce a reducer without a reason.
6. Keep Related State Together
A reducer works particularly well when multiple values form one logical state model.
7. Keep Reducers Focused
A reducer should represent one feature or state domain rather than becoming a giant application-wide function.
8. Separate Side Effects
Use event handlers and Effects for external interactions.
9. Use Consistent Action Naming
For example:
"itemAdded""itemRemoved""formSubmitted""requestSucceeded"
Consistency improves readability.
Interview questions & answers
Interview preparation
8 questions grouped by difficulty — expand any card for a model answer and follow-up probe.
Beginner
1What is useReducer?
BeginnerModel answer
useReduceris a React Hook for managing state through a reducer function and dispatched actions.2What is a reducer?
BeginnerModel answer
A reducer is a pure function that receives the current state and an action and returns the next state.function reducer(state, action) { → return nextState; → }3Can useReducer perform API calls?
BeginnerModel answer
The reducer itself should not perform API calls.
API requests belong in event handlers, Effects, or other appropriate application logic.
The result can then be dispatched as an action.
Intermediate
4What are the arguments of useReducer?
IntermediateModel answer
The basic form is:useReducer(reducer, initialState);It can also accept an optional initialization function:useReducer(reducer, initialArg, init);5Why should reducers be pure?
IntermediateModel answer
Pure reducers are predictable, easier to test, and easier to reason about.
They should not perform side effects or mutate existing state.
6Can useReducer be used with Context?
IntermediateModel answer
Yes. A common pattern is to provide bothstateanddispatchthrough Context so multiple components can participate in the same feature-level state management.
Advanced
7What does dispatch do?
AdvancedModel answer
dispatchsends an action describing an event to the reducer.dispatch({ type: "increment" });8When should you use useReducer instead of useState?
AdvancedModel answer
UseuseReducerwhen state transitions are complex, numerous, or closely related. UseuseStatewhen state management is simple.
Practice Exercises
Exercise 1: Counter Reducer
Create a counter using useReducer with:
- increment
- decrement
- reset
- increment by a specified amount
Exercise 2: Todo Reducer
Build a Todo application with actions:
addTodoremoveTodotoggleTodoclearTodos
Keep all Todo state transitions inside the reducer.
Exercise 3: Form Reducer
Create a registration form with:
- name
- password
- submitting state
- error state
- success state
Use actions such as:
fieldChangedsubmitStartedsubmitSucceededsubmitFailedformReset
Exercise 4: Shopping Cart
Build a shopping cart using useReducer.
Support:
- add item
- remove item
- increase quantity
- decrease quantity
- clear cart
Exercise 5: Request State
Create a reducer that manages:
idleloadingsuccesserror
Then connect it to an asynchronous request using an Effect.
Mini Project: Task Manager with useReducer
Build a Task Manager with:
State
{tasks: [],filter: "all"}
Actions
taskAddedtaskToggledtaskDeletedfilterChangedtasksCleared
Requirements
- Add a task.
- Mark a task complete.
- Delete a task.
- Filter by all/active/completed.
- Clear completed tasks.
- Keep all state transitions inside the reducer.
- Avoid mutating arrays.
- Keep filtering logic derived during rendering rather than storing filtered tasks separately.
A possible structure:
TaskManager↓useReducer↓taskReducer↓dispatch(action)↓new state↓render
This project combines several React concepts learned so far:
- components
- props
- events
- state
- immutable updates
- conditional rendering
- lists and keys
- Hooks
useReducer Cheat Sheet
Import
import { useReducer } from "react";
Initial state
const initialState = {count: 0,};
Reducer
function reducer(state, action) {switch (action.type) {case "increment":return {...state,count: state.count + 1,};default:return state;}}
Hook
const [state, dispatch] = useReducer(reducer,initialState);
Dispatch
dispatch({type: "increment",});
Dispatch with data
dispatch({type: "incrementBy",payload: 5,});
Reset
dispatch({type: "reset",});
Core model
State+Action↓Reducer↓New State↓Render