Lesson 11. Simplify state update logic with reducers 2. Example with a reducer
First, in an empty file, let’s define an initial state for the logic we want to implement:
const initialState = {loading: false,error: false,planet: null,}
Then, let’s create the reducer function in charge of updating the state:
const reducer = (state, action) => {switch (action.type) {case 'load':return { ...state, loading: true, error: false }case 'success':return { ...state, loading: false, planet: action.planet }case 'error':return { ...state, loading: false, error: true }default:throw new Error('Unhandled action')}}
Here we assume that the reducer will receive an action with one of the types
load, success, or error. Otherwise, we throw an exception since there is
no reason to receive another action (this is a difference with Redux: here, we
do not declare a global reducer, so we expect to know all the actions that the
reducer can handle).
Note that the reducer must respect the following rules:
- it should not update the state directly but return a new version of it. As a
result, we use the spread operator
...instead of just writingstate.loading = true. - it should not have side effects: it is not the right place to trigger a call to an API, starting a timer, or updating the DOM of the displayed page.
- it must be a pure function, i.e., for given parameters (state and action), it should always return the same result (new state).
Using the reducer in a component
Now that we have our reducer and the initial state, let’s create our component in charge of calling the reducer and displaying a text accordingly to what the state contains:
export const Planet = () => {const [state, dispatch] = useReducer(reducer, initialState)const { loading, error, planet } = stateif (loading) {return <p>Loading…</p>} else if (error) {return <p>An error occurred.</p>} else if (planet) {return <p>Planet: {planet.name}</p>} else {return null}}
As you can see, React provides the useReducer hook, which accepts two
parameters: the reducer function and the initial state. It returns two values in
an array: the current state and a dispatch function that we will see in a
minute.
The state value contains the state precisely as we defined it, first in our
initial state and then in the reducer's return values. It includes three
attributes: loading, error, and planet, which we can use to decide what to
render.
The only missing piece is the useEffect block to call the API and update the
state: we will use the dispatch function returned by useReducer.
Dispatching actions
Consider the dispatch function as an evolved version of the setters returned
by useState. For instance, with useState you would write:
setLoading(true)setError(false)
With useReducer, you dispatch an action to the reducer. It is then the
reducer’s responsibility to update the state with the right values:
dispatch({ type: 'load' })
To call our API, this is how we can write our logic in a useEffect:
useEffect(() => {let active = truedispatch({ type: 'load' })fetch('https://swapi.dev/api/planets/1/').then((res) => res.json()).then((planet) => {if (active) {dispatch({ type: 'success', planet })}}).catch((error) => {if (active) {console.error(error)dispatch({ type: 'error' })}})return () => (active = false)}, [])
It should remind you of the logic we saw in lesson 3 about the async pattern.
But instead of updating the state using a setter, we dispatch actions using the
dispatch function returned by the call to useReducer. This function accepts
one parameter: the action object to dispatch.
Here we dispatch three kinds of actions:
- Just before calling
fetch, we dispatch aloadaction, - When the request succeeds, we dispatch a
successaction, with an attribute containing the result,planet, - And when an error occurred, we dispatch an
erroraction.
Notice that we never call the reducer directly, nor do we update the state
ourselves by any means. We read the state as useReducer returns it, and
dispatch actions using the dispatch function. It is useReducer’s
responsibility to call the reducer with the state and the action and update the
state accordingly.
Improving the action creation
Creating the actions objects can be a little annoying, plus it is easy to make mistakes in the type or the additional attributes, especially if you don’t use TypeScript. This is why a good practice consists in creating functions to create each action:
const fetchStart = () => ({ type: 'load' })const fetchSuccess = (planet) => ({ type: 'success', planet })const fetchError = () => ({ type: 'error' })
Then you can call these functions instead of creating the objects in the calls
to dispatch:
dispatch(fetchStart())fetch('https://swapi.dev/api/planets/1/').then((res) => res.json()).then((planet) => {dispatch(fetchSuccess(planet))}).catch((error) => {console.error(error)dispatch(fetchError())})
Complete code
Here is the final code for our example:
Result