This blog post takes for granted that you have some knowledge regarding React and React's Hooks.
Managing state in React
As you probably know, React has 2 ways to manage state:
Both are widely used across any given React application, and although they ultimately serve the same purpose (managing state), they should be used in different situations.
When to use useReducer
vs useState
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. - React's docs
As stated in the paragraph above, the useReducer
hook should be opted in when the logic behind your state is a bit more complex or depends on the previous state.
✅ Good use cases for useReducer
:
- Changing 1 piece of state also changes others (co-related state values);
- The state is complex and has a lot of moving parts;
- When you want/need more predictable state transitions;
The useReducer
hook
Now that we have some context on where to use this hook, it's time to take a closer look at it's API.
useReducer
it's a built in function brought by React that has 2 different signatures:
useReducer(reducer, initialArg);
useReducer(reducer, initialArg, init);
useReducer
arguments
reducer
The reducer
as it's own name indicates, it's a function that takes some information and reduces it into something, and this is the place where the "magic" happens.
It takes two arguments, the current state
and the action
which is dispatched by the UI. By taking a given action type, a reducer will return the next piece of state, usually by deriving the previous state.
function counterReducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
}
}
initialState
This argument is pretty self explanatory, it's just the state that the useReducer
hook will start with.
init
init
is a function that allows you do some logic around the initial state, as it will take the value you passed as initialState
and return a "new" initialState
based on that.
function init(initialCount) {
return {count: initialCount};
}
useReducer
returned values
Very similar to useState
, this hook returns an array with two values:
- The first, to show the current state;
- The second, a way to change the state, and create a re-render in the application.
const [state, dispatch] = useReducer(counterReducer, initialState);
state
This value doesn't need much explanation, it is simply the current state returned by the useReducer
hook.
dispatch
This is a function where you can pass the the possible actions
that you define for your reducer
to handle. Taking the previous counterReducer
by example, these could look like this:
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
Managing the fetching logic with the useReducer
hook
Now that we have a better understanding of what the useReducer
hook can do for us, it's time to get our hands dirty and make usage of this React hook to handle any given fetching related state.
Fetching state
In order to use useReducer
, you must first think what will be the state that you want to manage, these are usually all the things that you might have in a bunch of useState
hooks, like data
, errorMessage
, fetchState
, etc...
In this scenario, as we want to create a hook that will allow us to manage fetching logic. And as far as fetch logic goes, all the pieces you need are:
-
state: to know if the application is
iddle
,loading
, if the fetch was asuccess
or afailure
- error: a error message in case something went wrong
- data : the response data
And so, now that we have our state
structure defined, we can setup our initialState
.
// "iddle" state because we haven't fetch anything yet!
const initialState = {
status: "idle",
data: null,
error: null,
};
Fetching reducer
Actions
The second step, is to create the logic that will lead to different app states. That logic lives under the reducer
function and for us to mount that logic, we should start by thinking on the "actions" that we need to perform.
For the fetching logic, we will need the following actions:
- FETCH: action to be called when the request starts;
- RESOLVE: action to be called if the response is successful;
- REJECT: action to be called if the requests throws an error or the response is "invalid";
Bear in mind that you can call these actions whatever you want, as long as they reflect what's being done and it makes sense for you.
State transitions
Each of these actions (FETCH
, RESOLVE
and REJECT
) will lead to a state transition, thus, producing a new output (a new state).
So now, it's just a matter of figuring out which will be the state that each of these actions will output.
Implementing useReducer
With all the pseudo-code and decisions we have made above, we are now able to take advantage of useReducer
to manage the fetching logic:
const initialState = {
status: "idle",
data: null,
error: null
};
function fetchReducer(currentState, action) {
switch (action.type) {
case "FETCH":
return {
...currentState,
status: "loading"
};
case "RESOLVE":
return {
status: "success",
data: action.data,
error: null
};
case "REJECT":
return {
data: null,
status: "failure",
error: action.error
};
default:
return currentState;
}
}
const [state, dispatch] = React.useReducer(fetchReducer, initialState);
}
Fetching data
The implementation code is done, let's now check how would the code look look if we were fetching some data through our useReducer
.
function fetchIt() {
// Start fetching!
dispatch({ type: "FETCH" });
fetch("https://www.reddit.com/r/padel.json")
.then((response) =>
response.json().then((result) => {
// We got our data!
dispatch({ type: "RESOLVE", data: result });
})
)
.catch((error) => {
// We got an error!
dispatch({ type: "REJECT", data: error });
});
}
return (
<>
{state.status === "loading" ? <p>loading...</p> : undefined}
{state.status === "success" ? <p>{JSON.stringify(state.data)}</p> : undefined}
{state.status === "failure" ? <p>{JSON.stringify(state.error)}</p> : undefined}
<button disabled={state.status === "loading"} onClick={fetchIt}>
Fetch Data
</button>
</>
);
Creating useFetchReducer
custom hook
Now, you will probably want to use this very same code to control your application's state in every place where you are performing an HTTP request.
Luckily for us, React brings a huge composition power packed in, making our life's quite simple when creating custom hooks through other existing React hooks (useReducer
in this case).
Extracting useReducer
hook
The 1st step, is to create a new file named use-fetch-reducer.js
or whatever you wanna call it, as long and it starts with use (to be identified as an hook).
The 2nd step, is to take (copy) all the code that we implemented before, and paste it inside an exported function with the name useFetchReducer
. It should look something like this:
import React from "react";
export function useFetchReducer() {
const initialState = {
status: "idle",
data: null,
error: null
};
function fetchReducer(currentState, action) {
switch (action.type) {
case "FETCH":
return {
...currentState,
status: "loading"
};
case "RESOLVE":
return {
status: "success",
data: action.data,
error: null
};
case "REJECT":
return {
data: null,
status: "failure",
error: action.error
};
default:
return currentState;
}
}
const [state, dispatch] = React.useReducer(fetchReducer, initialState);
}
The 3rd step is to take out our useReducer
result and return it instead, so that we can use state
and dispatch
in every other component:
//...
return React.useReducer(fetchReducer, initialState);
To wrap things up, we should make this hook as "generic" as possible, so that it can satisfy the need of every component where it's being called from. In order to get there, the 4th step passes by providing a way for consumers to set the initialData
themselves, because it might not always start as null
:
function useFetchReducer(initialData = null) {
const initialState = {
status: "idle",
data: initialData,
error: null
};
//...
Using useFetchReducer
- Import the newly created hook into your component;
- Execute it as
const [state, dispatch] = useFetchReducer();
- Use it's
state
anddispatch
as you would for theuseReducer
hook.
Conclusion
If your app state is becoming somewhat complex and the number of useState
is mounting up, it might be time to do a small switch and take advantage of useReducer
instead.
If you have decided to use useReducer
, follow these steps:
- Think of the State you want to manage;
- Think of the Actions that that will trigger state transitions;
- Think for the State Transitions that will happen when calling the defined set of states.
With these thought out, it's time to write your own reducer and call the useReducer
hook.
If the logic you just created can be reused across your application, create a custom hook and enjoy 😉
The 2nd part of this series will bring some type safety to the table, make sure to follow me on twitter if you don't want to miss it!
P.S. the useFetchReducer
code was highly inspired into David K. Piano's code, present in this great blog post.
_
Top comments (1)
The power of useRreducer! 🐱👤