useReducer
is usually preferable touseState
, when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one.
Initialization
Similar to useState
, when called, useReducer
returns an array of two items. The first being our current state and the second being a dispatch method. We assign these two returned values to variables using array destructuring.
const [state, dispatch] = useReducer(reducer, initialState);
useReducer
takes two arguments and (and an optional 3rd which we will cover later). The first argument is a reducer function, and the second is our initial state value, similar to useState
.
What is a Reducer?
Reducer functions are not specific to React. They are simply Javascript functions that take in two arguments: an initial value, and instructions for what to do to that value. The reducer applies some sort of logic to the value based on the instructions that you provided and returns an entirely new value.
const reducer = (value, instructions) => newValue
An important thing to understand about reducers is that they will always only return one value. Reducers are pure functions that reduce the original input into a single return value without mutating the original value that was passed in and, given the same arguments, will always produce the same return value.
A good example of this pattern in Javascript is the .reduce()
array method. As with useReducer
, this method takes two arguments: a reducer function and the initial value from which to apply the reducer function against.
const nums = [1, 2, 3]
const initialValue = 0
const reducer = (accumulator, item) => accumulator + item
const total = nums.reduce(reducer, initialValue)
console.log(nums) // [1, 2, 3]
console.log(total) // 6
In this example, .reduce()
loops through our nums
array, and applies our reducer
function for each iteration. Our initialValue
is what we want the reducer to use as its starting point on the first iteration. The accumulator
is the collected value returned in the last invocation that informs the function what the next value will be added to.
1st iteration: 0 + 1 => 1
2nd iteration: 1 + 2 => 3
3rd iteration: 3 + 3 => 6
The nums
array was reduced into the single return value of 6.
How are Reducers Used in React?
In React, reducers are responsible for handling transitions from one state to the next state in your application. The initial value we provide to the reducer is our current state and the instructions we provide are called actions.
The current state and the action go in, the new state comes out the other side.
const reducer = (state, action) => newState
Reducer functions handle state transitions by determining what to do based on information provided by the action.
Actions
Actions express unique events that happen throughout your application. From user interaction with the page, external interaction through network requests, and direct interaction with device APIs, these and more events can be described with actions.
Here are some general conventions for actions described by the Flux standard for action objects:
An action MUST
- be a plain JavaScript object;
- have a
type
property
An action MAY
- have an
error
property. - have a
payload
property. - have a
meta
property.
An action MUST NOT include properties other than type
, payload
, error
, and meta
.
action.type
The type
of an action identifies to the consumer the nature of the action that has occurred. type
is a string constant. If two types are the same, they MUST be strictly equivalent (using ===
).
// Action with type property
{
type: 'ADD_TODO'
}
action.payload
The optional payload
property MAY be any type of value. It represents the payload of the action. Any information about the action that is not the type
or status of the action should be part of the payload
field.
// Action with type and payload properties
{
type: 'ADD_TODO',
payload: {
todo,
completed: false,
id: id()
},
}
action.error
The optional error
property MAY be set to true
if the action represents an error.
An action whose error
is true is analogous to a rejected Promise. By convention, if error
is true
, the payload
SHOULD be an error object. This is akin to rejecting a promise with an error object.
// Action representing an error. The error property is set to true, therefore the payload is an error object.
{
type: 'ADD_TODO',
payload: new Error(),
error: true
}
action.meta
The optional meta
property MAY be any type of value. It is intended for any extra information that is not part of the payload.
Dispatching Actions
As I mentioned at the beginning, when initialized, useReducer
returns an array of two items. The first being our current state and the second being a dispatch method.
const [todos, dispatch] = useReducer(reducer, [])
When invoked, this dispatch method is responsible for passing an action to our reducer function.
Actions are dispatched when specific events take place. In following with the todo app example used thus far, these events could be represented by actions such as:
- Adding a todo
- Deleting a todo
- Toggling whether a todo item is completed or not.
Let's create some action types for these events.
const ADD_TODO = 'ADD_TODO'
const DELETE_TODO = 'DELETE_TODO'
const TOGGLE_COMPLETED = 'TOGGLE_COMPLETED'
We could use strings throughout our application when using these action types, but by assigning them to variables, we avoid the issue of misspelling the string, which would not throw an error, leading to wasted time spent tracking down the bug. If we misspell the variable name, we will get a useful error message telling us what we did wrong.
Now lets add some handler functions that will call dispatch, passing it an action object. These handlers will be triggered when certain events take place.
// calls dispatch, passing it an action object with a type property of ADD_TODO,
// and a payload property containing the todo text that was passed in,
// a default value of false for the completed property, and a unique id.
const addTodo = todo => {
dispatch({
type: ADD_TODO,
payload: {
todo,
completed: false,
id: id()
}
});
};
// calls dispatch, passing it an action object with a type property of DELETE_TODO,
// and accepts an id which is the only property in our payload.
const deleteTodo = id => {
dispatch({
type: DELETE_TODO,
payload: {
id
}
});
};
// calls dispatch, passing it an action object with a type property of TOGGLE_COMPLETED,
// and accepts an id which is the only property in our payload.
const completeTodo = id => {
dispatch({
type: TOGGLE_COMPLETED,
payload: {
id
}
});
};
Each action, when dispatched, will be handled differently by our reducer. A common pattern you will see with reducers is the use of switch statements. This isn't a requirement and any conditional logic will do so long as we are optimizing for readability. For the sake of showing something other than a switch statement, here is what a reducer for handling our todo app might look like with an if-else statement.
const todoReducer = (state, action) => {
if (action.type === ADD_TODO) {
return [action.payload, ...state]
}
if (action.type === DELETE_TODO) {
return state.filter(todo => todo.id !== action.payload.id)
}
if (action.type === TOGGLE_COMPLETED) {
return state.map(todo => {
if (todo.id !== action.payload.id) return todo
return {...todo, completed: !todo.completed}
})
}
return state
}
The above reducer knows what to do when given each type of action.
If the dispatched action has a type property of ADD_TODO:
- Return a copy of the current state, adding the new todo to the beginning of the array.
If the dispatched action has a type property of DELETE_TODO:
- Filter our list of todos, returning a new list of all todos whose id does not match the id passed with our action's payload, therefore removing the todo item from the list.
If the dispatched action has a type property of TOGGLE_COMPLETED:
- Loop through our list of todos, looking for the todo whose id property matches the id from the action's payload. If they do not match, return the todo item as is. If a match is found, copy the todo item's properties, replacing the
completed
property with the opposite of what it was.
If none of those are true and we receive an unrecognized action, return the current state as is.
Putting It All Together
We have covered the basic components of how to use the reducer hook for managing more complex state. Let's look at a more practical example of using useReducer
for managing state in a typical contact form component.
Let's start by building out the very basic structure of our form component.
import React, { useReducer } from 'react'
const Form = () => {
// for now, we will just prevent the default
// behaviour upon submission
handleSubmit = e => {
e.preventDefault()
}
return (
<>
<h1>Send a Message</h1>
<form onSubmit={handleSubmit}>
<label htmlFor='name'>
Name
<input id='name' name='name' type='text' />
</label>
<label htmlFor='email'>
Email
<input id='email' name='email' type='email' />
</label>
<label htmlFor='subject'>
Subject
<input id='subject' name='subject' type='text' />
</label>
<label htmlFor='body'>
Body
<textarea id='body' name='body' />
</label>
<button type='submit'>
Send
</button>
</form>
</>
)
}
export default Form
Next, let's declare our action types, an object representing our initial state, and our reducer function. You can declare these inside of your component or out, or write them in a separate file and import them where needed. For this example, I will be declaring them in the same file, but outside of our component to keep our <Form />
a bit less cluttered and easier to read.
We also need to initialize our useReducer
hook, passing it our newly created reducer function and initial state object.
For variety, I will use a switch statement in our reducer.
import React, { useReducer } from 'react'
// action types
const UPDATE_FIELD_VALUE = 'UPDATE_FIELD_VALUE'
// initial state
const INITIAL_STATE = {
name: '',
email: '',
subject: '',
body: '',
}
// reducer function
const formReducer = (state, action) => {
switch (action.type) {
case UPDATE_FIELD_VALUE:
return { ...state, [action.payload.field]: action.payload.value }
default:
return INITIAL_STATE
}
// form component
const Form = () => {
// initialize useReducer
const [state, dispatch] = useReducer(formReducer, INITIAL_STATE)
...
Now we need to give control of our inputs over to React so that we can store the input values in state.
First, let's set the value of each input to the respective value stored in state.
<input
id='name'
name='name'
type='text'
value={state.name}
/>
Doing this alone will disable our input because we have hardcoded the value to an empty string with no instructions for how to handle the change event.
So, we also need to provide an onChange
attribute to our input and pass to it a function so that we can update the values stored in state.
<input
id='name'
name='name'
type='text'
value={state.name}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
/>
And our updateFieldValue
handler function:
const updateFieldValue = (field, value) => {
dispatch({
type: UPDATE_FIELD_VALUE,
payload: {
field,
value,
},
})
}
Now when a user types in our input field, the updateFieldValue
function is triggered, which dispatches an action to our formReducer
with a type of UPDATE_FIELD_VALUE
, and a payload which includes the field that was updated, and the new value of that field.
Our formReducer
knows what to do with this action type and returns a new state with the updated field values.
Here is what our Form component looks like thus far:
import React, { useReducer } from 'react'
// initial state values
const INITIAL_STATE = {
name: '',
email: '',
subject: '',
body: '',
}
// action types
const UPDATE_FIELD_VALUE = 'UPDATE_FIELD_VALUE'
// reducer function
const formReducer = (state, action) => {
switch (action.type) {
case UPDATE_FIELD_VALUE:
return { ...state, [action.payload.field]: action.payload.value }
default:
return INITIAL_STATE
}
}
// Form component
const Form = () => {
const [state, dispatch] = useReducer(formReducer, INITIAL_STATE)
// input change handler function
const updateFieldValue = (field, value) => {
dispatch({
type: UPDATE_FIELD_VALUE,
payload: {
field,
value,
},
})
}
// submit handler
const handleSubmit = event => {
event.preventDefault()
}
return (
<>
<h1>Send a Message</h1>
<form onSubmit={handleSubmit}>
<label htmlFor='name'>
Name
<input
id='name'
name='name'
type='text'
value={state.name}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<label htmlFor='email'>
Email
<input
id='email'
name='email'
type='email'
value={state.email}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<label htmlFor='subject'>
Subject
<input
id='subject'
name='subject'
type='text'
value={state.subject}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
/>
</label>
<label htmlFor='body'>
Body
<textarea
id='body'
name='body'
type='text'
value={state.body}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<button type='submit'>
Send
</button>
</form>
</>
)
}
export default Form
Our form is successfully using the reducer hook to update and keep track of our input values in state. Now we need to handle the various states associated with submitting the form and display those states to the user.
Adding Form States
At this point, we only have one type of action for updating the values of our various input fields in state. This alone is a valid use case for useReducer
, but when thinking about all of the states involved with submitting a form, updating and storing the input values is only one small piece of the equation.
Here are a few of the common states that our form could be in:
- Idle: Our initial state. An empty form, ready to be filled out and submitted;
- Pending: We submitted the form and are waiting to find out if the submission was successful or not;
- Success: Our form was submitted successfully;
- Error: Something went wrong while trying to send the form;
All of these form states need to be tracked and communicated to the user. Each status will be represented by a different UI.
Let's add a new action type for representing these state changes:
// action types
const UPDATE_FIELD_VALUE = 'UPDATE_FIELD_VALUE'
const UPDATE_STATUS = 'UPDATE_STATUS'
Similar to our action types, I'm going to declare a few new variables for our current form states to avoid the issue I mentioned earlier with using strings instead of variables. We want useful error messages if we end up making a spelling mistake.
// form status variables
const IDLE = 'UPDATE_FIELD_VALUE'
const PENDING = 'PENDING'
const SUCCESS = 'SUCCESS'
const ERROR = 'ERROR'
Also add a new status
property to our initial state with default value of IDLE
// initial state
const INITIAL_STATE = {
name: '',
email: '',
subject: '',
body: '',
status: IDLE,
}
We now need to add a new case
for dealing with an action type of UPDATE_STATUS
. If an action is dispatched with a type of UPDATE_STATUS
, we return a copy of the state as is, replacing the value of our status
property with the new value from our actions's payload.
// reducer function
const formReducer = (state, action) => {
switch (action.type) {
case UPDATE_FIELD_VALUE:
return { ...state, [action.payload.field]: action.payload.value }
case UPDATE_STATUS:
return { ...state, status: action.payload.status }
default:
return INITIAL_STATE
}
Inside of our Form
component, let's add a new handler function for communicating that an UPDATE_STATUS
event has occurred. We will call this handler updateStatus
.
// Form component
const Form = () => {
const [state, dispatch] = useReducer(formReducer, INITIAL_STATE)
// handler functions
const updateFieldValue = (field, value) => {
dispatch({
type: UPDATE_FIELD_VALUE,
payload: {
field,
value,
},
})
}
const updateStatus = status => {
dispatch({
type: UPDATE_STATUS,
payload: {
status,
},
})
}
...
We can now give our handleSubmit
function the logic for updating the status
property in state. Typically, you would send a POST
request to some sort of API responsible for handling incoming messages in a useEffect
hook. This API would then communicate whether or not this was successful by providing an error response or a success response. For now, we will mock this functionality by initially setting our status
to PENDING
, then after two seconds, setting its value to SUCCESS
.
...
// submit handler
const handleSubmit = event => {
event.preventDefault()
updateStatus(PENDING)
setTimeout(() => {
updateStatus(SUCCESS)
}, 2000)
}
...
Now in our form, we can add some markup for displaying IDLE
, PENDING
, SUCCESS
, and ERROR
states to the user.
...
// Success state
if (state.status === SUCCESS) {
return <p>Your message was sent successfully.</p>
}
// Error state
if (state.status === ERROR) {
return <p>Oops! Something went wrong...</p>
}
// Default State
return (
<>
<h1>Send a Message</h1>
<form onSubmit={handleSubmit}>
<label htmlFor='name'>
Name
<input
id='name'
name='name'
type='text'
value={state.name}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<label htmlFor='email'>
Email
<input
id='email'
name='email'
type='email'
value={state.email}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<label htmlFor='subject'>
Subject
<input
id='subject'
name='subject'
type='text'
value={state.subject}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
/>
</label>
<label htmlFor='body'>
Body
<textarea
id='body'
name='body'
type='text'
value={state.body}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<button type='submit' disabled={state.status === PENDING}>
{state.status !== PENDING ? 'Send' : 'Sending...'}
</button>
</form>
</>
)
}
export default Form
With this in place, upon submission of our form, the status
is set to PENDING
for two seconds, which disables the submit button and changes the button text to Sending... instead of Send.
After two seconds, the status
is set to SUCCESS
which renders the message Your message was sent successfully. instead of our form.
To see the ERROR
message right now, you can hardcode the status
to ERROR
in the INITIAL_STATE
, which will display the message Oops! Something went wrong... instead of our form.
At this point, we have the base functionality in place for managing state in most forms. You will still need to swap out our submit handler with real functionality and also write your styles for helping communicate the various form states.
The only missing piece is a reset button for allowing the user to send another message upon a successful or unsuccessful submit attempt. For this, we will utilize the optional third parameter to useReducer
that I mentioned at the beginning of this article.
Lazy Initialization
useReducer
also gives us the ability to create the initial state lazily. To do this, you can pass an init
function as the optional third argument.
The initial state will be set to init(initialState)
.
const [todos, dispatch] = useReducer(reducer, initialState, init);
The init
function lets you extract the logic for calculating the initial state outside of the reducer. This is also handy for resetting the state to its initial values in response to an action.
In our case, this action will have a type of RESET
, so lets add another action type for this:
//action types
const UPDATE_FIELD_VALUE = 'UPDATE_FIELD_VALUE'
const UPDATE_STATUS = 'UPDATE_STATUS'
const RESET = 'RESET'
Declare our init function:
// init function passed as optional 3rd argument for lazy initialization
const init = initialState => initialState
Add a new case for for handling the new action type
// reducer function
const formReducer = (state, action) => {
switch (action.type) {
case UPDATE_FIELD_VALUE:
return { ...state, [action.payload.field]: action.payload.value }
case UPDATE_STATUS:
return { ...state, status: action.payload.status }
case RESET:
return init(INITIAL_STATE)
default:
return INITIAL_STATE
}
}
Pass our init function as the third argument to useReducer
:
// Form component
...
const Form = () => {
const [state, dispatch] = useReducer(formReducer, INITIAL_STATE, init)
...
Add a new handler function:
...
const resetForm = () => {
dispatch({ type: RESET })
}
...
And lastly, update our SUCCESS
and ERROR
UI's to include a button that triggers our resetForm
handler function, setting the form back to its original state and displaying that to the user.
...
// Success state
if (state.status === SUCCESS) {
return (
<>
<p>Your message was sent successfully.</p>
<button type='button' onClick={resetForm}>
Send Another Message
</button>
</>
)
}
// Error state
if (state.status === ERROR) {
return (
<>
<p>Something went wrong...</p>
<button type='button' onClick={resetForm}>
Try Again
</button>
</>
)
}
...
Our Finished Form Component
import React, { useReducer } from 'react'
// form status variables
const IDLE = 'UPDATE_FIELD_VALUE'
const PENDING = 'PENDING'
const SUCCESS = 'SUCCESS'
const ERROR = 'ERROR'
// initial state values
const INITIAL_STATE = {
name: '',
email: '',
subject: '',
body: '',
status: IDLE,
}
// action types
const UPDATE_FIELD_VALUE = 'UPDATE_FIELD_VALUE'
const UPDATE_STATUS = 'UPDATE_STATUS'
const RESET = 'RESET'
// 3rd parameter for lazy initialization
const init = initialState => initialState
// reducer function
const formReducer = (state, action) => {
switch (action.type) {
case UPDATE_FIELD_VALUE:
return { ...state, [action.payload.field]: action.payload.value }
case UPDATE_STATUS:
return { ...state, status: action.payload.status }
case RESET:
return init(INITIAL_STATE)
default:
return INITIAL_STATE
}
}
// Form component
const Form = () => {
const [state, dispatch] = useReducer(formReducer, INITIAL_STATE, init)
// handler functions
const updateFieldValue = (field, value) => {
dispatch({
type: UPDATE_FIELD_VALUE,
payload: {
field,
value,
},
})
}
const updateStatus = status => {
dispatch({
type: UPDATE_STATUS,
payload: {
status,
},
})
}
const resetForm = () => {
dispatch({ type: RESET })
}
// MOCK submit handler
const handleSubmit = event => {
event.preventDefault()
updateStatus(PENDING)
setTimeout(() => {
updateStatus(SUCCESS)
}, 2000)
}
// Success state UI
if (state.status === SUCCESS) {
return (
<>
<p>Your message was sent successfully.</p>
<button type='button' onClick={resetForm}>
Send Another Message
</button>
</>
)
}
// Error state UI
if (state.status === ERROR) {
return (
<>
<p>Something went wrong...</p>
<button type='button' onClick={resetForm}>
Try Again
</button>
</>
)
}
// Default state UI
return (
<>
<h1>Send a Message</h1>
<form onSubmit={handleSubmit}>
<label htmlFor='name'>
Name
<input
id='name'
name='name'
type='text'
value={state.name}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<label htmlFor='email'>
Email
<input
id='email'
name='email'
type='email'
value={state.email}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<label htmlFor='subject'>
Subject
<input
id='subject'
name='subject'
type='text'
value={state.subject}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
/>
</label>
<label htmlFor='body'>
Body
<textarea
id='body'
name='body'
type='text'
value={state.body}
onChange={e => updateFieldValue(e.target.name, e.target.value)}
required
/>
</label>
<button type='submit' disabled={state.status === PENDING}>
{state.status !== PENDING ? 'Send' : 'Sending...'}
</button>
</form>
</>
)
}
export default Form
Recap
useReducer
is preferable touseState
when you have complex state logic that involves multiple sub-values or when the next state depends on the previous one;- When called,
useReducer
returns an array of two items: the current state, and a dispatch method; useReducer
accepts three arguments: A reducer function, the initial state, and the optional init function for lazy initialization of state;- In React, reducers are responsible for handling transitions from one state to the next state in your application. Reducers take in the current state and an action and return an entirely new state;
- Actions express unique events that happen throughout your application.
- A few general conventions for actions have been described by the Flux standard for action objects;
- Actions are dispatched to our reducer when specific events take place;
Thanks for reading!
Top comments (10)
It's a good post!! The only thing I'm not agree about is the naming of action creaters you've used on
updateStatus
andreset
functions. They are more like dispatch callers/invokers. An action creater would be something like thislet fnAC = ()=>({type: 'valueOfACObject'})
Ah, thank you! That is a really good point. I completely agree. My "action creators" are not just creating actions, but also calling the dispatch function so using that term is not accurate. I am going to revise this. Thinking I will just keep the handler functions and remove the concept of "action creators" altogether as I like having these handlers much more.
This is probably the best article on useReducer, it should be linked in the official docs.
Well explained and demoed.
Wow, thank you so much, Olivier. That means a lot. I'm glad you got something out of it.
Thanks for sharing.
Nice article and thank you for hsaring, Brett~
Also loved the summary in the beginning of the article.
Thank you, Sung! I really appreciate that.
You're welcome and thank you for sharing~
Indeed one of the best explanations so far!! Thanks a lot for putting this together!
The 3rd argument (init) was giving me a hard time , but your explanation was simple and to the point. Cheers!
Hey QuantumK9! Thanks so much for that response. I'm glad you were able to get some value out of this post.