useReduceris 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
typeproperty
An action MAY
- have an
errorproperty. - have a
payloadproperty. - have a
metaproperty.
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
completedproperty 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
useReduceris preferable touseStatewhen you have complex state logic that involves multiple sub-values or when the next state depends on the previous one;- When called,
useReducerreturns an array of two items: the current state, and a dispatch method; useReduceraccepts 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)
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.
It's a good post!! The only thing I'm not agree about is the naming of action creaters you've used on
updateStatusandresetfunctions. 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.
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.