My personal reference working with useReducer
`useReducer` helps us to manage mutiple states. It manages every state within a central place.To understand `useReducer`, I like to think about it as different people having different responsibilities.
useReducer in React
This is my personal go to area in working with useReducer.
useReducer helps us to manage mutiple states. It manages every state within a central place.
I am going to demonstrate this using creating an account comprising email, password and name. It has severall component parts.
The Main Parts of useReducer
To understand useReducer, I like to think about it as different people having different responsibilities.
The State — The Vault/Books
The State is the current truth of your application.
For example:
name = ""
email = ""
password = ""
loading = true
The state holds the information about what is currently happening in our application.
The Action
The Action is a structured note describing what you want to do.
It has a type, for example:
"WITHDRAW_MONEY"
or:
"UPDATE_EMAIL"
And it can have a payload, which is the actual information you want to send.
For example:
"alice@gmail.com"
Dispatch
You can't talk directly to the reducer.
Instead, you talk to the reducer using:
dispatch()
dispatch is responsible for sending the instruction.
The Reducer
The Reducer is the function which does all the changes.
The reducer receives the instruction from dispatch, looks at the current State, performs the calculation, and writes down the New State.
So the process looks like this:
User does something
↓
dispatch()
↓
Action
↓
Reducer
↓
New State
useReducer is structured such that duties are delegated.
For example, when a user types something, the dispatch captures the type of change that occurred, e.g.:
"CHANGE_FIELD"
The payload, i.e. field and value, is then sent to the reducer.
The reducer now looks at the current state and performs the logic based on the type and returns the new state.
How Do We Know the Action Types?
To know the kinds of action type you will have in your application, imagine the user interacting with your form.
Write down every event they can trigger.
Step 1: Write Down the Events
| User interaction | Event | Action type |
| ---------------------------------------- | -------------------- | ---------------- |
| User types in an input | Something changed | CHANGE_FIELD |
| User clicks a "Clear" or "Reset" button | Form reset | RESET_FORM |
| User clicks "Submit" | Submission started | SUBMIT_START |
| The database saves the data successfully | Submission succeeded | SUBMIT_SUCCESS |
| The server responds with an error | Submission failed | SUBMIT_ERROR |
The idea is simple:
Think about what the user can do, then create action types around those events.
Step 2: Does the Reducer Need Extra Information?
Once you know the events, ask yourself:
"Does the clerk (reducer) need extra information to process this event?"
For RESET_FORM, the clerk doesn't need to know anything else.
RESET_FORM
For CHANGE_FIELD, the clerk needs to know which input changed and what the new value is.
For SUBMIT_ERROR, the clerk needs to know what went wrong to show the user.
NB
It's better to group similar user interactions into a single action type, like:
CHANGE_FIELD
and pass details in the payload instead of using:
setName
setEmail
change_password
etc.
This keeps your reducer clean, scalable, and easy to maintain.
Step 1: Describe Our TypeScript Types
First we describe our TypeScript types.
What Does Our Form Data Look Like?
interface FormFields {
name: string;
email: string;
password: string;
}
This describes the information that belongs to our form.
We have:
name
email
password
What Does Our Form State Look Like?
interface FormState {
name: string;
email: string;
password: string;
data: FormFields
}
What Actions Is the User Allowed to Request?
For actions we consider what the user can do with the form.
type FormAction =
| { type: "SUBMIT_START" }
| { type: "SUBMIT_SUCCESS" }
| { type: "SUBMIT_ERROR"; error: string }
| { type: "CHANGE_FIELD"; field: keyof FormFields; value: string }
| { type: "RESET_FORM" };
Here we have several possible actions:
SUBMIT_START
SUBMIT_SUCCESS
SUBMIT_ERROR
CHANGE_FIELD
RESET_FORM
Each action describes something that can happen in our application.
Step 2: Write the Reducer Function
The reducer is a pure function.
It takes the current state and the action, and returns a brand new state.
It never changes the existing state directly.
No mutating!
function reducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SUBMIT_START":
return {
...state,
loading: true,
error: null,
success: false
};
case "SUBMIT_SUCCESS":
return {
...state,
loading: false,
success: true,
error: null,
payload: {email: "", password: ""}
};
case "SUBMIT_ERROR":
return {
...state,
loading: false,
error: action.error,
success: false
};
case "CHANGE_FIELD":
return {
...state,
data: {
...state.data,
[action.field]: action.value,
},
};
case "RESET_FORM":
return initialState;
default:
return state;
}
}
The reducer receives two things:
state
and:
action
Then we use:
switch (action.type)
to determine what should happen.
For example, when:
action.type === "CHANGE_FIELD"
the reducer changes the appropriate field.
Step 3: Put It to Work in the Component
Now, we wire it up inside our React component using useReducer.
export default function Test() {
const [open, setOpen] = useState(false);
const [state, dispatch] = useReducer(
formReducer,
initialState
);
// Close modal on Escape key press
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () =>
window.removeEventListener("keydown", handleKeyDown);
}, [open]);
// Handle Action Submit (Simulated API Call)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
dispatch({ type: "SUBMIT_START" });
try {
const res = await fetch("/api/sign-up", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(state.data)
});
const g = await res.json();
if (!res.ok) {
throw new Error(g.message || "Network error");
}
dispatch({ type: "SUBMIT_SUCCESS" });
dispatch({ type: "RESET_FORM" });
setOpen(false); // Close modal on success
} catch (err: any) {
dispatch({
type: "SUBMIT_ERROR",
error:
err.message ||
"Something went wrong. Please try again."
});
}
};
return (
<div className="mt-6 flex justify-center p-4">
{/* Trigger Button */}
<button
onClick={() => setOpen(true)}
className="flex items-center gap-2 px-5 py-2.5 bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-800 text-white font-medium rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
>
<ArrowBigDownDashIcon className="w-5 h-5" />
<span>Add Sale</span>
</button>
{/* Modal Wrapper */}
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="fixed inset-0 bg-slate-950/60 backdrop-blur-sm transition-opacity"
onClick={() => setOpen(false)}
/>
{/* Modal Card */}
<div className="relative w-full max-w-md bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-2xl overflow-hidden transform transition-all p-6 flex flex-col gap-4 z-10 animate-in fade-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between pb-2 border-b border-slate-100 dark:border-slate-800">
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
Create a Product
</h2>
<button
onClick={() => setOpen(false)}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 rounded-md p-1 transition-colors"
aria-label="Close modal"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
{state.error && (
<div className="text-sm p-3 bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 rounded-lg border border-red-100 dark:border-red-900/50">
{state.error}
</div>
)}
{/* Name field */}
<div className="flex flex-col gap-1.5">
<label
htmlFor="name"
className="text-xs font-medium uppercase tracking-wider text-slate-500 dark:text-slate-400"
>
Name
</label>
<input
type="text"
id="name"
required
value={state.data.name}
onChange={(e) =>
dispatch({
type: "CHANGE_FIELD",
field: "name",
value: e.target.value
})
}
placeholder="John Doe"
className="w-full rounded-lg border border-slate-200 dark:border-slate-800 bg-transparent px-3 py-2 text-sm text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<button
type="submit"
disabled={state.loading}
className="w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium text-white bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg shadow-sm transition-colors"
>
{state.loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Creating...</span>
</>
) : (
<span>Create Product</span>
)}
</button>
</form>
</div>
</div>
)}
</div>
);
}
Let's Understand What Is Happening
Let's slow down and look at the important part.
When the user types inside the input:
onChange={(e) =>
dispatch({
type: "CHANGE_FIELD",
field: "name",
value: e.target.value
})
}
We are not directly changing the state.
We are sending a message to the reducer.
The message says:
Hey reducer!
The user changed a field.
The type is CHANGE_FIELD.
The field is name.
The new value is whatever the user typed.
The reducer receives this:
{
type: "CHANGE_FIELD",
field: "name",
value: e.target.value
}
Then the reducer looks at:
action.type
and finds:
case "CHANGE_FIELD":
It then performs the logic and returns the new state.
What Happens When We Submit?
When the user submits the form:
dispatch({ type: "SUBMIT_START" });
The reducer receives:
{
type: "SUBMIT_START"
}
The reducer then changes the state to indicate that submission has started.
After the API request succeeds:
dispatch({ type: "SUBMIT_SUCCESS" });
If something goes wrong:
dispatch({
type: "SUBMIT_ERROR",
error: err.message
});
So our form has a flow:
User submits form
↓
SUBMIT_START
↓
API request
↓
┌─────┴─────┐
↓ ↓
Success Error
↓ ↓
SUBMIT_ SUBMIT_
SUCCESS ERROR
Questions I Would Ask My Students
Can we bypass the Reducer?
Can we change:
state.name = "Bob"
directly?
Answer: No.
The state is read-only.
We must always dispatch an action.
What Is the Role of dispatch?
What is the role of:
dispatch()
Answer:
It's the messenger.
It delivers our Action request slip to the Reducer.
Think about it like this:
You
↓
dispatch()
↓
Action request slip
↓
Reducer
↓
New State
Why Do We Write ...state Inside the Reducer?
Why do we write:
...state
inside the reducer?
Answer:
React state is immutable.
...state
copies the other fields, like email and password, so they don't get erased when we update the name.
For example:
return {
...state,
data: {
...state.data,
[action.field]: action.value
}
};
We are saying:
Keep everything that was already there, but update this particular field.
The Big Picture
When working with useReducer, remember the four important things:
STATE
↓
The current truth
ACTION
↓
What happened / what we want to do
DISPATCH
↓
The messenger
REDUCER
↓
The person responsible for changing the state
And the complete process is:
User interaction
↓
dispatch()
↓
Action
↓
Reducer
↓
New State
↓
React updates UI
This is my personal go-to way of thinking about useReducer.
Comments (0)
No comments yet. Be the first to share your thoughts.