So Why Are We Talking About useReducer
?
Let’s be honest. useState
It's cool… until it isn’t.
Simple counter? Sure. But forms with 5+ fields, validation, resetting, and conditional rendering? Suddenly, your component has 12 useState
calls, updates flying around, and you’re juggling spaghetti.
That’s where useReducer
comes in. It gives you structure, type safety, and a clear flow of how your component updates. Like building a tiny Redux, but built-in and lightweight.
Wanna learn about useState? Here is a detailed article: useState in detail
Use cases are simple
-
You’re managing complex state objects (like form inputs or nested structures)
-
State changes are based on actions, not direct setters
-
You want a predictable state transition flow
-
You’re tired of writing setForm({...form, name: e.target.value })
The useReducer
Flow
Here’s the pattern every useReducer
setup follows:
-
Define your state type
-
Define your action types
-
Write the reducer function
-
Initialize the state
-
Call useReducer(reducer, initialState)
in the component
-
Dispatch actions to update state
Let’s break it with a super basic counter.
Counter Example. Understanding the Full Flow
1. State and Action Types
export type CounterState = {
count: number;
}
export type CounterAction = { type: 'increment' } | { type: 'decrement' };
State has a count: number
Actions can be either 'increment'
or 'decrement'
2. Reducer Function
Here's a tiny state machine for counting. You send in an action like 'increment'
, and it returns the new count (new state).
import type { CounterState, CounterAction } from "./types";
export const counterReducer = (state: CounterState, action: CounterAction): CounterState => {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
return state;
}
};
Breakdown of code
Defining a reducer: a function that takes the current state and an action, and returns the new state.
export const counterReducer = (
state: CounterState,
action: CounterAction
): CounterState => {
Depending on the action.type
You either increase or decrease the count
by 1.
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
If it gets an action it doesn’t recognize, it just returns the existing state.
default:
return state;
3. Component
import React, { useReducer } from "react";
import { counterReducer } from "../reducer";
import { type CounterState } from "../types";
export default function Counter() {
const initialState: CounterState = { count: 0 };
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<div>
<h2>{state.count}</h2>
<button onClick={() => dispatch({ type: 'increment' })}> + </button>
<button onClick={() => dispatch({ type: 'decrement' })}> - </button>
</div>
);
}
Breakdown of code
const initialState: CounterState = { count: 0 };
Initialize the state, this is the starting point: count
Begins at 0.
const [state, dispatch] = useReducer(counterReducer, initialState);
-
state
is whatever the reducer returns.
-
dispatch
is how you yell at the reducer to change state.
-
Under the hood, React wires counterReducer
to your component’s lifecycle.
return (
<div>
<h2>{state.count}</h2>
<button onClick={() => dispatch({ type: 'increment' })}> + </button>
<button onClick={() => dispatch({ type: 'decrement' })}> - </button>
</div>
);
}
-
UI shows the current count.
-
+
button fires dispatch({ type: 'increment' })
reducer bumps the count.
-
-
button fires dispatch({ type: 'decrement' })
reducer drops the count.
-
React re-renders automatically with the new state, no extra hooks, no manual updates.
![]()
Pretty nice! Now let's see the real life use case.
Now the Real Use Case: Managing a Complex Form
Let’s take it up a notch. A real-world form with multiple inputs, validation, errors, and reset.
Let's back to our framework:
1. State and Action Types
export type FormState = {
name: string;
email: string;
age: string;
errors: {
name: string;
email: string;
age: string;
};
};
type UpdateForm = {
type: 'update';
field: string;
value: string;
}
type SetFormError = {
type: 'setError';
errors: FormState['errors'];
}
type FormRest = {
type: 'reset';
}
export type FormAction = UpdateForm | SetFormError | FormRest
Breakdown of code
export type FormState = {
name: string;
email: string;
age: string;
errors: {
name: string;
email: string;
age: string;
};
};
Here we are defining what the state should look like inside the form.
-
name
, email
, age
: regular input fields (all string
)
-
errors
: a nested object tracking validation errors for each field
This way, the state holds both data and its validation result.
Pro tip: Keeping errors inside state is avoids extra hooks and keeps it local.
Now the actions:
// "Yo reducer, update this field with this value.
type UpdateForm = {
type: 'update';
field: string;
value: string;
};
//Update the errors object with a new one, probably after a validation run.
type SetFormError = {
type: 'setError';
errors: FormState['errors'];
// this syntax means, the errors property in this action should match exactly the errors type inside FormState
};
// This just resets the whole form back to its initial state.
type FormReset = {
type: 'reset';
};
Union:
export type FormAction = UpdateForm | SetFormError | FormReset;
Step 2: Reducer Function
import type { FormState, FormAction } from "./types";
export const intialFormState: FormState = {
name: '',
email: '',
age: '',
errors: {
name: "",
email: "",
age: ""
}
};
export const formReducer = (state: FormState, action: FormAction): FormState => {
switch (action.type) {
case 'update':
return {
...state,
[action.field]: action.value,
errors: { ...state.errors, [action.field] : undefined }
};
case "reset":
return intialFormState;
case "setError":
return {
...state,
errors: action.errors
}
default:
return state;
}
}
Breakdown of code
export const initialFormState: FormState = {
name: '',
email: '',
age: '',
errors: {
name: "",
email: "",
age: ""
}
};
The Initial State: We feed into useReducer
as the initial value.
export const formReducer = (state: FormState, action: FormAction): FormState => {
The Reducer Function: It gets the current state
and a FormAction
, and it returns the new state.
Cases
Case: 'update'
case 'update':
return {
...state,
[action.field]: action.value,
errors: {
...state.errors,
[action.field] : undefined
}
};
/*
...state
Start with what I already have
[action.field]: action.value
- action.field is a string, like 'name', 'email', or 'age'
- action.value is whatever the user typed
errors: {
...state.errors,
[action.field]: undefined
}
...state.errors: keeps all current error messages as-is
[action.field]: undefined: clears the error for the field the user is editing
Because if the user starts changing a field, you probably want to remove the old error until you revalidate.
*/
Case: 'reset'
case 'reset':
return initialFormState;
Resets the entire form state back to the initial empty values.
Case: 'setError'
case 'setError':
return {
...state,
errors: action.errors
}
Drops in new error messages, usually after validating the form on submit. This is where the magic happens when you say: “Hey, that email is garbage” or “Age must be a number.”
Step 3: Hook It All Up in the Component
// Form.tsx
import { useReducer } from "react";
import { formReducer, intialFormState } from "../reducer";
export default function Form() {
const [state, dispatch] = useReducer(formReducer, intialFormState);
const validate = () => {
const errors: typeof state.errors = {
name: '',
email: '',
age: ''
};
if (!state.name.trim()) errors.name = 'Name is required';
if (!state.email.includes('@')) errors.email = 'Invalid email';
if (!/^\d+$/.test(state.age)) errors.age = 'Age must be a number';
return errors;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const errors = validate();
if (Object.values(errors).some(err => err)) {
dispatch({ type: 'setError', errors });
} else {
alert(`Submitted: ${JSON.stringify(state, null, 2)}`);
dispatch({ type: 'reset' });
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label>Name:</label>
<input
value={state.name}
onChange={(e) =>
dispatch({ type: 'update', field: 'name', value: e.target.value })
}
/>
{state.errors.name && <span style={{ color: 'red' }}>{state.errors.name}</span>}
</div>
<div>
<label>Email:</label>
<input
value={state.email}
onChange={(e) =>
dispatch({ type: 'update', field: 'email', value: e.target.value })
}
/>
{state.errors.email && <span style={{ color: 'red' }}>{state.errors.email}</span>}
</div>
<div>
<label>Age:</label>
<input
value={state.age}
onChange={(e) =>
dispatch({ type: 'update', field: 'age', value: e.target.value })
}
/>
{state.errors.age && <span style={{ color: 'red' }}>{state.errors.age}</span>}
</div>
<button type="submit">Submit</button>
<button type="button" onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
</form>
);
}
Breakdown of code
1. useReducer(formReducer, intialFormState)
const [state, dispatch] = useReducer(...)
/*
Instead of doing:
const [name, setName] = useState('');
const [email, setEmail] = useState('');
useReducer(formReducer, intialFormState)
Replaces multiple useState() calls.
state = the full form state.
dispatch = how you update it.
*/
2. validate()
function: The Rule Checker
const validate = () => {
const errors: typeof state.errors = {
name: '',
email: '',
age: ''
};
if (!state.name.trim()) errors.name = 'Name is required'; //If name is blank > error.
if (!state.email.includes('@')) errors.email = 'Invalid email'; //If email doesn't have @ > error.
if (!/^\d+$/.test(state.age)) errors.age = 'Age must be a number'; //If age isn't all digits > error.
return errors;
};
/*
Returns something like:
{
name: "Name is required",
email: "",
age: ""
}
If everything's good? Returns empty strings.
*/
What’s typeof state.errors
?
"Hey, this errors
object should have the exact same shape as state.errors
."
Which means: { name: string, email: string, age: string }
3. handleSubmit()
: When the user clicks Submit
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const errors = validate();
if (Object.values(errors).some(err => err)) {
dispatch({ type: 'setError', errors });
}
else {
alert(`Submitted: ${JSON.stringify(state, null, 2)}`);
dispatch({ type: 'reset' });
}
};
-
e.preventDefault()
: stops the form from refreshing the page.
-
validate()
: runs checks and gets error messages.
-
Object.values(errors).some(err => err)
: checks if any error message exists.
-
If yes: send errors to state using dispatch({ type: 'setError', errors })
-
If not: alert the data and reset the form with dispatch({ type: 'reset' })
-
What is dispatch({ type: '...' })
?
-
This is how we "talk" to the reducer and say: “Yo reducer, update the state based on this action.”
-
You don’t call setState()
manually, reducer handles everything.
4. The Form UI
<input
value={state.name}
onChange={(e) =>
dispatch({ type: 'update', field: 'name', value: e.target.value })
}
/>
{state.errors.name && <span>{state.errors.name}</span>}
The same pattern is repeated for email and age.
5. Buttons
<button type="submit">Submit</button>
When clicked, triggers onSubmit={handleSubmit}
on the form. It runs validation and either submits or sets errors.
Reset button
<button type="button" onClick={() => dispatch({ type: 'reset' })}>
Reset
</button>
This clears everything back to how it started, using the 'reset'
action in the reducer.
Final Structure Recap
Inputs ➝ dispatch ➝ reducer ➝ state ➝ validate ➝ errors/output
Here’s how the flow works:
-
You write stuff in the input, dispatch
sends update to reducer
-
You submit, validate()
checks the values
-
If errors exist, they’re shown below the inputs
-
If not, form is submitted and reset
![]()
Best Practices With useReducer
+ TS
- Always type your state and action: Don’t rely on
any
. Let TS be your guard.
- Group actions smartly: No need for 10 different action types if you can generalize (like the
update
action in the form).
- Split reducer and types: into separate files in real apps. Keep the code maintainable.
- Use
dispatch
smartly: Don’t call it inside loops or conditionals without reason.
Don’t try to handle side effects inside reducers. Keep them pure.
Don’t use useReducer
if you only have 1-2 state values. It’s not always the better tool, use it when you need structure.
Conclusion
If useState
is a sharp knife, useReducer
is your full toolbox. Once you learn the pattern, state, action, reducer, and dispatch, it clicks.
Don’t wait to use this only in “big apps”. Use it in your next form, or even a multi-step wizard, and you’ll see why it’s a game-changer.