State is one of the most important concepts in React.
It allows components to remember information between renders and update the user interface when that information changes.
You have already learned useState, which is the simplest and most commonly used State Hook.
React also provides useReducer, which is useful when state logic becomes more complex.
Both Hooks solve the same broad problem:
How should a component remember and update information?
The difference is mainly in how the state transitions are organized.
A simple component might use:
const [count, setCount] = useState(0);
A component with many related state transitions might use:
const [state, dispatch] = useReducer(
reducer,
initialState
);
In this chapter, you will learn:
useStateuseReducer- Reducers
- Actions
- Dispatch
- Complex state management
- When to choose
useState - When
useReducermakes more sense
useState
useState is a React Hook used to add state to a function component.
You import it from React:
import { useState } from "react";
Then call it:
const [count, setCount] = useState(0);
The Hook returns an array containing two values:
Current state
State setter
In this example:
const [count, setCount] = useState(0);
count contains the current value.
setCount is the function used to request a state update.
Basic Example
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button
onClick={() =>
setCount((value) => value + 1)
}
>
Increase
</button>
</div>
);
}
When the button is clicked, React schedules a state update and renders the component again with the new state.
State Can Store Different Types
useState can store:
const [name, setName] = useState("");
A string.
const [age, setAge] = useState(25);
A number.
const [isOpen, setIsOpen] = useState(false);
A boolean.
It can also store objects:
const [user, setUser] = useState({
name: "Alex",
age: 25
});
And arrays:
const [items, setItems] = useState([]);
Updating State
For a simple value:
setCount(10);
For an update based on the previous value:
setCount((currentCount) =>
currentCount + 1
);
The functional form is especially useful when the next value depends on the previous value.
Updating Objects
Do not directly mutate the state object.
Avoid:
user.name = "John";
Instead:
setUser((currentUser) => ({
...currentUser,
name: "John"
}));
The spread operator creates a new object while preserving the properties you did not change.
Updating Arrays
Avoid:
items.push(newItem);
Instead:
setItems((currentItems) => [
...currentItems,
newItem
]);
For removing an item:
setItems((currentItems) =>
currentItems.filter(
(item) => item.id !== itemId
)
);
useState is usually the right choice when state updates are straightforward.
When Should You Use useState?
useState works well when:
- A component has a few state values
- Updates are simple
- State transitions are easy to understand
- You do not need a large collection of related actions
- The update logic can remain close to the component
For example:
const [isMenuOpen, setIsMenuOpen] =
useState(false);
There is little benefit in using a reducer for such a simple piece of state.
useReducer
useReducer is another State Hook.
It is useful when state updates involve more complex logic or multiple related transitions.
You import it from React:
import { useReducer } from "react";
The basic syntax is:
const [state, dispatch] = useReducer(
reducer,
initialState
);
It returns:
state: the current statedispatch: a function used to request a state transition
The reducer determines how the state should change.
Basic Example
import { useReducer } from "react";
function reducer(state, action) {
if (action.type === "increment") {
return {
count: state.count + 1
};
}
return state;
}
function Counter() {
const [state, dispatch] = useReducer(
reducer,
{ count: 0 }
);
return (
<div>
<p>{state.count}</p>
<button
onClick={() =>
dispatch({ type: "increment" })
}
>
Increase
</button>
</div>
);
}
The flow is:
User interaction
↓
dispatch(action)
↓
reducer(state, action)
↓
new state
↓
React renders UI
This separates the description of an event from the logic that determines how state changes.
Why Use useReducer?
Imagine an application with state such as:
const [user, setUser] = useState({
name: "",
email: "",
age: 0,
isLoggedIn: false,
isLoading: false,
error: null
});
There may be many different operations:
- Update name
- Update email
- Update age
- Start loading
- Finish loading
- Log in
- Log out
- Set an error
- Clear an error
With enough transitions, a large collection of setUser calls can become difficult to follow.
A reducer can centralize these transitions.
const [state, dispatch] = useReducer(
reducer,
initialState
);
Then actions describe what happened:
dispatch({
type: "update_email",
value: "alex@example.com"
});
The reducer determines how that event changes the state.
Reducers
A reducer is a function that determines the next state based on the current state and an action.
The standard shape is:
function reducer(state, action) {
// return next state
}
A reducer receives:
Current state
+
Action
and returns:
Next state
Conceptually:
(state, action)
↓
reducer
↓
new state
Simple Reducer
function reducer(state, action) {
switch (action.type) {
case "increment":
return {
...state,
count: state.count + 1
};
case "decrement":
return {
...state,
count: state.count - 1
};
default:
return state;
}
}
The reducer does not directly update the existing state object.
Instead, it returns a new state.
Reducers Should Be Pure
A reducer should be a pure function.
That means it should calculate the next state from its inputs without causing unrelated side effects.
For example:
function reducer(state, action) {
switch (action.type) {
case "increment":
return {
...state,
count: state.count + 1
};
default:
return state;
}
}
This is predictable.
Avoid putting things such as network requests, timers, or DOM manipulation directly inside a reducer.
Instead, handle those operations outside the reducer and dispatch actions based on what happens.
Do Not Mutate State in a Reducer
Avoid:
function reducer(state, action) {
state.count++;
return state;
}
Instead:
function reducer(state, action) {
return {
...state,
count: state.count + 1
};
}
The reducer should return the next state rather than mutating the existing state.
Image Placement: Reducer Flow
Place this image immediately after the explanation of reducers.
Create a modern 16:9 educational infographic titled “How useReducer Works in React”. Show Current State flowing into a Reducer, alongside an Action, producing New State, which then updates the UI. Include the flow dispatch(action) → reducer(state, action) → new state → render. Use a polished Web4Learners developer aesthetic with rounded cards, subtle gradients, clean typography, minimal text, and a modern React-inspired interface.
Actions
An action is an object that describes what happened.
A typical action contains a type:
{
type: "increment"
}
For actions that need additional information, you can include a payload or other fields:
{
type: "set_name",
value: "Alex"
}
Or:
{
type: "add_item",
item: {
id: 1,
name: "Laptop"
}
}
The action does not normally perform the state update itself.
It describes the event.
The reducer decides how the state should change.
Action Example
Suppose the state is:
{
count: 0
}
You can dispatch:
dispatch({
type: "increment"
});
The reducer receives:
function reducer(state, action) {
and checks:
action.type
Then:
case "increment":
returns the new state.
Actions Describe Events
A useful way to think about actions is:
"increment"
"item_added"
"item_removed"
"logged_in"
"logged_out"
"form_submitted"
They describe something that happened.
For example:
dispatch({
type: "item_added",
item: newItem
});
The action communicates:
An item was added.
The reducer determines what that means for the state.
Payload
A payload contains additional information needed by the reducer.
For example:
dispatch({
type: "set_quantity",
productId: 10,
quantity: 3
});
The reducer can use those values:
function reducer(state, action) {
switch (action.type) {
case "set_quantity":
return {
...state,
quantity: action.quantity
};
default:
return state;
}
}
The exact action structure is a design choice.
The important principle is to make actions clear and predictable.
Dispatch
dispatch is the function returned by useReducer.
For example:
const [state, dispatch] = useReducer(
reducer,
initialState
);
You use dispatch to send an action to the reducer.
dispatch({
type: "increment"
});
React then uses that action with the reducer to determine the next state.
Dispatching Different Actions
Suppose:
function reducer(state, action) {
switch (action.type) {
case "increment":
return {
...state,
count: state.count + 1
};
case "decrement":
return {
...state,
count: state.count - 1
};
case "reset":
return {
...state,
count: 0
};
default:
return state;
}
}
You can dispatch:
dispatch({
type: "increment"
});
or:
dispatch({
type: "decrement"
});
or:
dispatch({
type: "reset"
});
The UI does not need to know how each state transition is implemented.
It simply describes what happened.
Complete useReducer Counter
Here is a complete example:
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 "reset":
return initialState;
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(
reducer,
initialState
);
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>
);
}
The component has three possible actions:
increment
decrement
reset
The reducer contains the state-transition logic.
Passing Data with Actions
Actions become more useful when they carry information.
Imagine a shopping cart.
dispatch({
type: "add_item",
item: {
id: 1,
name: "Keyboard",
price: 50
}
});
The reducer can handle it:
function reducer(state, action) {
switch (action.type) {
case "add_item":
return {
...state,
items: [
...state.items,
action.item
]
};
default:
return state;
}
}
The component describes the event.
The reducer handles the state transition.
Complex State Management
useReducer becomes particularly useful when state contains several related values and many different ways to update them.
For example:
const initialState = {
items: [],
total: 0,
status: "idle",
error: null
};
Possible events include:
add item
remove item
update quantity
start checkout
checkout success
checkout failure
clear cart
Managing all of these transitions with independent setters can become difficult.
A reducer provides a central place for the state transition logic.
Shopping Cart Example
Let’s build a simplified shopping cart.
const initialState = {
items: []
};
function reducer(state, action) {
switch (action.type) {
case "add_item":
return {
...state,
items: [
...state.items,
action.item
]
};
case "remove_item":
return {
...state,
items: state.items.filter(
(item) =>
item.id !== action.itemId
)
};
case "clear_cart":
return {
...state,
items: []
};
default:
return state;
}
}
The component:
function ShoppingCart() {
const [state, dispatch] = useReducer(
reducer,
initialState
);
function addItem(item) {
dispatch({
type: "add_item",
item
});
}
function removeItem(itemId) {
dispatch({
type: "remove_item",
itemId
});
}
function clearCart() {
dispatch({
type: "clear_cart"
});
}
return (
<div>
<p>
Items: {state.items.length}
</p>
<button
onClick={() =>
addItem({
id: 1,
name: "Keyboard"
})
}
>
Add Keyboard
</button>
<button onClick={clearCart}>
Clear Cart
</button>
</div>
);
}
As the application grows, more actions can be added without spreading state-transition logic throughout the component.
useState vs useReducer
Both Hooks manage state, but they have different strengths.
useState
Use useState when state transitions are simple.
const [isOpen, setIsOpen] =
useState(false);
This is easy to understand.
useReducer
Use useReducer when many related transitions exist.
const [state, dispatch] =
useReducer(reducer, initialState);
This makes state transitions explicit.
Comparison
useState | useReducer |
|---|---|
| Simple state | More complex state |
| Direct setter | Dispatch actions |
| Update logic often near event handler | Update logic centralized in reducer |
| Easy for small components | Useful for many related transitions |
| Good for independent values | Good for state with related transitions |
Neither Hook is automatically better.
Choose the simplest approach that makes the state logic clear.
Converting useState to useReducer
Suppose you start with:
const [count, setCount] = useState(0);
You might update it with:
setCount((value) => value + 1);
With a reducer:
const [count, dispatch] =
useReducer(reducer, 0);
Then:
dispatch({
type: "increment"
});
and:
function reducer(count, action) {
switch (action.type) {
case "increment":
return count + 1;
default:
return count;
}
}
For a single counter, useReducer may be more code than necessary.
But if the state later becomes more complex, the reducer pattern can become more useful.
Complex Forms with useReducer
Forms are another common use case.
Suppose a form contains:
Name
Email
Password
Role
Newsletter
You could use several useState calls.
Or you could use:
const initialState = {
name: "",
email: "",
password: "",
role: "",
newsletter: false,
errors: {}
};
The reducer can manage different events.
function reducer(state, action) {
switch (action.type) {
case "field_changed":
return {
...state,
[action.field]: action.value
};
case "set_errors":
return {
...state,
errors: action.errors
};
case "reset":
return initialState;
default:
return state;
}
}
Then an input can dispatch:
dispatch({
type: "field_changed",
field: "email",
value: event.target.value
});
This approach can be useful when a form has many related state transitions.
Reducers with Nested Objects
Reducers can also manage nested state.
For example:
const initialState = {
user: {
profile: {
name: "",
city: ""
}
}
};
To update the city:
function reducer(state, action) {
switch (action.type) {
case "city_changed":
return {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
city: action.city
}
}
};
default:
return state;
}
}
Each level that changes needs to be represented by a new object.
For deeply nested state, you may want to reconsider the state structure if updates become unnecessarily difficult.
Reducer Composition
As applications grow, one reducer can become large.
You can organize related state logic into smaller reducers or other abstractions when appropriate.
For example:
Application
|
+-- user state
|
+-- cart state
|
+-- notification state
Each area can have its own state logic.
However, do not split reducers simply because the file is large. Split them when doing so makes the application’s state model easier to understand.
Reducer Actions Should Be Meaningful
Compare:
dispatch({
type: "setState",
value: {
count: 5
}
});
with:
dispatch({
type: "increment"
});
The second action communicates the event more clearly.
For more specific operations:
dispatch({
type: "item_removed",
itemId: 42
});
This describes what happened.
Meaningful action types make reducer logic easier to understand and debug.
Action Names
There is no single required naming style.
You may see:
increment
decrement
reset
add_item
remove_item
or:
INCREMENT
DECREMENT
RESET
ADD_ITEM
REMOVE_ITEM
Modern JavaScript applications commonly use lowercase strings, but the important thing is consistency within the project.
Default Case in a Reducer
A reducer should handle unknown actions safely.
For example:
function reducer(state, action) {
switch (action.type) {
case "increment":
return {
...state,
count: state.count + 1
};
default:
return state;
}
}
Returning the existing state for an unknown action is a common pattern.
Avoid silently returning an unrelated state structure.
Reducers and Side Effects
Reducers should calculate state.
They should not normally perform side effects such as:
API requests
Timers
DOM manipulation
Logging as application behavior
Random external operations
For example, avoid:
function reducer(state, action) {
fetch("/api/products");
return state;
}
Instead, perform the external operation elsewhere and dispatch an action based on the result.
Conceptually:
Component / Action
↓
External operation
↓
dispatch()
↓
Reducer
↓
New state
This keeps the reducer predictable.
dispatch Does Not Directly Change State
When you write:
dispatch({
type: "increment"
});
you are not directly modifying the state variable.
You are requesting a state transition.
React processes the update using the reducer and then renders the component with the resulting state.
This is similar to the way a state setter schedules an update with useState.
The current render’s state remains a snapshot.
Image Placement: State Management Comparison
Place this image before the useState vs useReducer comparison.
Create a modern 16:9 educational infographic comparing useState and useReducer. On the left show useState with a simple toggle or counter and direct state setter. On the right show useReducer with dispatch(action) → reducer → new state. Include a subtle visual cue that useState is suited to simpler transitions while useReducer is useful for more complex related state logic. Use a polished Web4Learners developer aesthetic with rounded cards, subtle gradients, clean typography, minimal text, and modern UI.
A Complete Complex State Example
Let’s build a small task manager.
The application needs to:
- Add tasks
- Remove tasks
- Toggle completion
- Clear completed tasks
This is a good situation for useReducer.
import { useReducer } from "react";
const initialState = {
tasks: []
};
function reducer(state, action) {
switch (action.type) {
case "task_added":
return {
...state,
tasks: [
...state.tasks,
{
id: crypto.randomUUID(),
text: action.text,
completed: false
}
]
};
case "task_toggled":
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.id
? {
...task,
completed: !task.completed
}
: task
)
};
case "task_removed":
return {
...state,
tasks: state.tasks.filter(
(task) => task.id !== action.id
)
};
case "completed_cleared":
return {
...state,
tasks: state.tasks.filter(
(task) => !task.completed
)
};
default:
return state;
}
}
function TaskManager() {
const [state, dispatch] =
useReducer(
reducer,
initialState
);
function addTask(text) {
dispatch({
type: "task_added",
text
});
}
return (
<div>
<button
onClick={() =>
addTask("Learn React")
}
>
Add Task
</button>
{state.tasks.map((task) => (
<div key={task.id}>
<span>
{task.text}
</span>
<button
onClick={() =>
dispatch({
type: "task_toggled",
id: task.id
})
}
>
Toggle
</button>
<button
onClick={() =>
dispatch({
type: "task_removed",
id: task.id
})
}
>
Remove
</button>
</div>
))}
<button
onClick={() =>
dispatch({
type: "completed_cleared"
})
}
>
Clear Completed
</button>
</div>
);
}
There are several important ideas here.
The State Is Centralized
The task collection lives in:
state.tasks
Events Are Described with Actions
Adding a task:
{
type: "task_added",
text
}
Toggling a task:
{
type: "task_toggled",
id
}
Removing a task:
{
type: "task_removed",
id
}
The Reducer Owns the Transition Logic
The component does not need to know how a task is added, removed, or toggled.
It only dispatches an action.
The reducer decides what the next state should be.
useReducer with Context
useReducer can also be combined with Context when multiple components need access to the same complex state.
A common architecture is:
Context Provider
|
+-- State
|
+-- Dispatch
|
+-- Child components
For example:
const [state, dispatch] =
useReducer(reducer, initialState);
Then the provider can make both available through Context.
Child components can read:
state
and send:
dispatch(action)
This pattern is useful for some application-level state, although it should not be used automatically for every piece of state.
You will explore Context and shared state patterns in more detail later.
When useReducer May Be a Better Fit
Consider useReducer when you notice patterns such as:
Many State Setters
If a component contains many related setters:
setUser(...)
setItems(...)
setError(...)
setStatus(...)
setSelected(...)
and these values change together in response to well-defined events, a reducer may make the transitions easier to understand.
Complex Conditional Updates
If every update contains complicated logic:
if (...)
if (...)
...
a reducer can centralize those transitions.
Related State Values
If several state values describe one process, a reducer can represent that process as a single state object.
Many User Actions
Applications such as:
- Shopping carts
- Task managers
- Multi-step forms
- Editors
- Games
- Complex filters
can sometimes benefit from reducer-based state management.
When useState Is Better
Do not use useReducer simply because it is more advanced.
For this:
const [isOpen, setIsOpen] =
useState(false);
a reducer would add unnecessary code.
Similarly:
const [name, setName] =
useState("");
is straightforward.
Use useState when it keeps the state logic simpler.
Common Mistakes with useReducer
Mutating State
Avoid:
state.items.push(action.item);
return state;
Instead:
return {
...state,
items: [
...state.items,
action.item
]
};
Forgetting to Return State
Every handled reducer case needs to return the appropriate next state.
Avoid:
case "increment":
state.count + 1;
Instead:
case "increment":
return {
...state,
count: state.count + 1
};
Mutating Nested Objects
Avoid:
state.user.name = "Alex";
return state;
Instead:
return {
...state,
user: {
...state.user,
name: "Alex"
}
};
Putting API Calls Inside Reducers
Reducers should calculate state transitions.
Perform external operations outside the reducer.
Creating Unclear Actions
Avoid vague actions such as:
{
type: "update"
}
when the application has many different types of updates.
Prefer meaningful actions:
{
type: "email_changed",
value: email
}
Using useReducer for Everything
A reducer is not automatically better than useState.
For simple state, useState is usually easier to read.
Best Practices for State Hooks
Start with useState
If your state logic is simple, start with useState.
You can move to useReducer if the state transitions become difficult to manage.
Keep Reducers Pure
A reducer should calculate the next state from:
state
+
action
Do Not Mutate State
Always return new objects or arrays when updating them.
Use Meaningful Action Types
Action names should communicate what happened.
Keep Related State Together
If several values belong to the same state machine or workflow, a reducer can make their relationships clearer.
Avoid Unnecessary State
If something can be calculated from existing state, consider deriving it rather than storing another copy.
Keep Reducer Logic Focused
The reducer should handle state transitions, not unrelated application responsibilities.
Use Stable IDs for Collections
When reducer-managed state contains lists, use stable identifiers for items and React keys.
State Hook Decision Guide
When you need state, ask these questions:
Is the state simple?
For example:
const [isOpen, setIsOpen] =
useState(false);
Use useState.
Are there several related transitions?
For example:
add
remove
update
reset
submit
error
success
Consider useReducer.
Does the next value depend on the previous value?
With useState, use a functional update:
setCount((value) => value + 1);
With useReducer, the reducer receives the current state:
function reducer(state, action) {
return {
...state,
count: state.count + 1
};
}
Do several components need the state?
Consider lifting the state to a common parent or using another appropriate sharing mechanism.
State Hooks Cheat Sheet
| Concept | Purpose |
|---|---|
useState | Manage straightforward component state |
useReducer | Manage state with more complex transitions |
| Reducer | Calculates the next state |
| Action | Describes what happened |
dispatch | Sends an action to the reducer |
| Initial state | Starting state for the component |
| Functional update | Calculates new state from previous state |
| Immutable update | Creates new objects or arrays instead of mutating existing state |
| Payload | Additional information carried by an action |
| Pure reducer | Reducer without unrelated side effects |
| Complex state | State with multiple related values and transitions |
useState vs useReducer Quick Example
useState
const [count, setCount] =
useState(0);
setCount((value) => value + 1);
Best when the update is simple.
useReducer
const [state, dispatch] =
useReducer(
reducer,
{ count: 0 }
);
dispatch({
type: "increment"
});
Best when state transitions benefit from explicit actions and centralized logic.
Neither pattern is inherently superior.
The right choice depends on the complexity of the state and how the component changes it.
Practice Exercise
Build a Web4Learners Course Manager using useReducer.
The state should contain:
{
courses: []
}
Each course should have:
{
id,
title,
completed
}
Create these actions:
course_added
course_completed
course_removed
courses_cleared
Your reducer should handle all four actions.
Add a Course
Dispatch:
dispatch({
type: "course_added",
title: "React Hooks"
});
Complete a Course
Dispatch:
dispatch({
type: "course_completed",
id: courseId
});
Remove a Course
Dispatch:
dispatch({
type: "course_removed",
id: courseId
});
Clear Courses
Dispatch:
dispatch({
type: "courses_cleared"
});
Then display:
- Course title
- Completion status
- Complete button
- Remove button
- Add Course button
- Clear Courses button
As an additional challenge, derive:
const completedCount = ...
from the courses instead of storing the count as separate state.
This will reinforce the relationship between reducers, actions, dispatch, immutable updates, and derived data.
Key Takeaways
useStateanduseReducerare React State Hooks.useStateis generally suitable for simple state and straightforward updates.useReduceris useful when state has multiple related transitions or complex update logic.- A reducer receives the current state and an action and returns the next state.
- An action describes what happened.
dispatchsends an action to the reducer.- Actions can contain additional information such as IDs, values, or objects.
- Reducers should be pure and should not perform unrelated side effects.
- Reducers should not directly mutate existing state.
- Objects and arrays should be updated immutably.
- Meaningful action names make reducer logic easier to understand.
useReducercan make complex state transitions more centralized and predictable.useReduceris not automatically better thanuseState.- Start with the simpler state management approach and introduce a reducer when it makes the logic clearer.
- Complex forms, shopping carts, task managers, editors, and other state-heavy interfaces can sometimes benefit from reducers.
- Derived values should usually be calculated from existing state rather than stored as duplicate state.
useReducercan be combined with Context when complex state needs to be shared across many components.- Good state management is less about choosing the most advanced Hook and more about choosing a structure that makes the application’s behavior easy to understand.