useEffect.dev
← Back to lessons

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 writing state.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 } = state
if (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 = true
dispatch({ 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 a load action,
  • When the request succeeds, we dispatch a success action, with an attribute containing the result, planet,
  • And when an error occurred, we dispatch an error action.

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