When to use the useReducer() hook?
You might be familiar with one of the React hooks called useReducer(). You know WHAT useReducer() is and you’re just wondering in which cases it’s useful and when you could just as well use useState()
There are cases wher…
-
useReducer() is definitely helpful
- When two or more states change together
- When your state relies on another state or previous state.
-
useReducer() could come in handy
- When you want to beautify / organize your code
- When your states get too complicated
- When you want to do less “plumbing”
- When you’d like an easier way to make tests
- When you don’t know if you should use useReducer()
-
useReducer() isn’t very useful
- When you only have one individual state(s) in a component
Using useReducer()
is never necessary. You can always use the useState() hook but in some cases it can make your life much better and your more code pro if you know when to use it.
Here’s all of that in more details…
When useReducer() is definitely helpful
- ###When two or more states change together
An example of when states change together is when you fetch data. You might have fx. three states: isLoading
, isError
and data
. Here’s a great article written by Robin Wieuruch on how to do data fetching with hooks. The second last part of his article shows how the data fetching component is refactored using useReducer()
instead of useState()
. Let’s take a look at some code (the examples come from Robin’s Wieuruch blog!)
useReducer()
:
const dataFetchReducer = (state, action) => {
switch (action.type) {
case "FETCH_INIT":
return {
...state,
isLoading: true,
isError: false,
}
case "FETCH_SUCCESS":
return {
...state,
isLoading: false,
isError: false,
data: action.payload,
}
case "FETCH_FAILURE":
return {
...state,
isLoading: false,
isError: true,
}
default:
throw new Error()
}
}
const useDataApi = (initialUrl, initialData) => {
const [url, setUrl] = useState(initialUrl)
const [state, dispatch] = useReducer(dataFetchReducer, {
isLoading: false,
isError: false,
data: initialData,
})
useEffect(() => {
const fetchData = async () => {
dispatch({ type: "FETCH_INIT" })
try {
const result = await axios(url)
dispatch({ type: "FETCH_SUCCESS", payload: result.data })
} catch (error) {
dispatch({ type: "FETCH_FAILURE" })
}
}
fetchData()
}, [url])
return [state, setUrl]
}
Every time we fire an action there are two or three states changing at once.
If this would have been written with useState()
, it would look like this:
const useDataApi = (initialUrl, initialData) => {
const [data, setData] = useState(initialData)
const [url, setUrl] = useState(initialUrl)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
useEffect(() => {
const fetchData = async () => {
setIsError(false)
setIsLoading(true)
try {
const result = await axios(url)
setData(result.data)
} catch (error) {
setIsError(true)
}
setIsLoading(false)
}
fetchData()
}, [url])
return [{ data, isLoading, isError }, setUrl]
}
The states, setData
, setIsLoading
, setIsError
were all made to serve the same purpose, to achieve data fetching. So keeping all those states together, using useReducer()
, is more readable and logical as they all change and belong together.
- ###When your state relies on another state or previous state.
The example above relies on previous state. We made use of the former state to merge and overwrite it with the new state. Another example could be a todo app where the new state adds to the former state.
Exercise: Create a todo app where you can add and remove items, using useState() and then refactoring it using useReducer(). The Solution
When useReducer() could come in handy
This is the most tricky part because there are no clear guidelines on when to use it and when not to use it. This comes down to personal preference.
- ###When you want to beautify / organize your code
This is mostly up to personal taste. Some find the code tidier when states are grouped into one useReducer()
.
Here’s a counter using useState()
:
function CounterState({ initialCount }) {
const [count, setCount] = useState(0)
return (
<>
useState() Count: {count}
<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
</>
)
}
And here you have a counter using useReducer()
:
const initialState = { count: 0 }
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 }
case "decrement":
return { count: state.count - 1 }
default:
throw new Error()
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<>
Count: {state.count}
<button onClick={() => dispatch({ type: "increment" })}>+</button>
<button onClick={() => dispatch({ type: "decrement" })}>-</button>
</>
)
}
Both of these examples are taken from the React Doc so both of them are fine. The more states you add that should belong together, the more it makes sense to use useReducer()
. Say for example that you’d add a “reset counter” action to start again counting from 0 –> then it’d make even more sense to use useReducer()
. Also, if you start with useReducer and then have to add more states, you won’t have to refactor it from useState into useReducer.
- ###When your states get too complicated
You have this complex problem to solve with lots of logic. Perhaps your components have so many states that in the end you don’t get your head around it. useReducer()
might help you reason about that logic.
- ###When you want to do less “plumbing”
Maybe you’ve already had to pass lots of props to another component. And that component has to pass it down to another component and that can go on and on… This is known as “plumbing” or “props drilling” and can become quite cumbersome as if you have to change/remove/add props, you’d have to do that on each level and most probably update your typescript/proptypes as well. When all those states belong together, you could use useReducer()
with either context
, group them together and pass them down the line. Here’s an example from React’s official website.
- ###When you’d like an easier way to make tests
Often when we use useState()
, we’ll have to create a new function (called handlers) to take care of the logic that updates the state. Here’s a code snippet from a todo app using useState()
:
(Sandbox example of the todo app):
const [todos, setTodos] = useState([]);
function addTodo(value) {
setTodos([...todos, { id: todos.length + 1, text: value ? value : todo }])
setTodo("")
}
function removeTodo(id) {
const newList = todos.filter(item => item.id !== id)
setTodos(newList)
...
<button onClick={() => addTodo()}>Add</button>
...
<button onClick={() => removeTodo(todo.id)}>Remove</button>
...
}
Whereas when we used useReducer()
in the todo app we call the same function, both for adding to the list or removing:
const [todoValue, setTodoValue] = useState();
function reducer(state, action) {
switch (action.type) {
case "remove":
const newList = state.filter(item => item.id !== action.id)
return newList
case "add":
setTodoValue("")
return [...state, { id: state.length + 1, text: todoValue }]
default:
throw new Error()
}
}
...
<button onClick={() => dispatch({ type: "add", text: todoValue })}>
...
<button onClick={() => dispatch({ type: "remove", id: todo.id })}>
Because there’s only one function, it becomes easier to test, easier to debug and also easier to reason about the component.
- ###When you don’t know if you should use useReducer()
When you don’t have much experience with useReducer()
, you can draft up your solution without useReducer()
because useState()
will always be able to get the job done and there’s nothing really wrong with it. You could always refactor it later.
When useReducer() isn’t very useful
- ###When you have only individual state(s) in a component
Sometimes you only have one state in a component, or several states that don’t belong together. Here’s an example of a very simple toggle button:
disclaimer: turning radio button into a checkbox is a unique situation that you’ll probably never have to reproduce ever in real life
useState():
function ToggleButtonState({ initialCount }) {
const [checked, toggle] = useState(false)
return (
<>
useState() : Toggle the radio button
<input
type="radio"
checked={checked}
onChange={functionThatReturnsNull}
onClick={() => toggle(!checked)}
/>
</>
)
}
vs. useReducer():
const initialState = { checked: false }
function reducer(state, action) {
switch (action.type) {
case "toggleCheck":
return { checked: !state.checked }
default:
throw new Error()
}
}
function ToggleButtonReducer() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<>
useReducer() : Toggle the radio button
<input
type="radio"
checked={state.checked}
onChange={functionThatReturnsNull}
onClick={() => dispatch({ type: "toggleCheck" })}
/>
</>
)
}
Here useReducer()
is a little too much.
##Summary
So now you hopefully have a better idea to use useReducer() efficiently in your React apps. If you’re not familiar with the javascript reducer() method or redux, it might take some time get your head around the useReducer. But as you start playing around with it and put it into action but as you see, it’s definitely worth it. It can be a time saver and levels up your code!