React — useReducer

Jeff P
7 min readJan 24, 2024

--

The useReducer hook can be a tad confusing when you first look at it. This is because there are three parts to making it work.

The first part

You have the hook itself….

  const [count, dispatch] = useReducer(reducer, 0);

It looks a bit similar to the useState() hook…..

const [count, setCount] = useState(0);

But one of the key differences here is that with useState, you typically de-structure the second part of the hook (the state setting function) and name it “set”[state name], e.g. if state is count, then you typically name the setting function to be setCount.

With useReducer, common convention is to just name the setter function “dispatch” (although technically you could name it whatever you want) because you’re not necessarily updating a single piece of state.

Also, you typically pass in two values into useReducer, as opposed to just 1 value with useState (the initial state value). Instead with useReducer() you pass in a function name, and then the initial state value.

useReducer(reducer, 0);

This first value, the function name (which again, can technically be called whatever you like) is usually just named as “reducer”, and this function is the second part of what makes the overall useReducer() hook work.

What’s important to understand though is that the second value, doesn’ necessarily need to be just a single value for initial state. In the example above, it’s just set to 0, but you can also pass in an object which could represent MULTIPLE pieces of state! This is a crucial difference between useState and useReducer.

So for example, rather than passing 0 as initial state, you could do something like this….

const initialState = {count: 0, step: 5};

const [state, dispatch] = useReducer(reducer, initialState);

So you can see that we’re managing the “count” piece of state AND the “step” piece of state at the same time!

The second part

The second part is the reducer function (not to be confused with the useReducer() hook!) that sits OUTSIDE of the component, and takes two parameters. It takes in a piece of state, and then it takes in an action, which is something you’re expecting to happen.

function reducer(state, action) {
// return something here....
}

Now whilst you normally return something with the reducer function, just for now we’ll make it log to the console….

function reducer(state, action) {
console.log(state, action);
}

So we can see that the reducer function takes in a piece of state, and then it takes in an “action”, which is something you’re expecting to happen.

Notice that in the example above, the goal when this reducer function gets called, is simply to print to the console the state value received, AND the action that occurred.

The third part

This leads us to the third part…the action itself!

So let’s say we have an event handler that gets called when a user clicks on a button. The event handler is called “increment”

  const increment = function () {
dispatch(1);
};

This is an example of a simple function that will in turn, call the dispatch function, and pass in a value of 1.

So if we click the button (which calls our “increment” function) this will call the dispatch function, and pass in a value of 1. We called our second de-structured value (function) from the useReducer hook “dispatch”, which is common convention, and this function is what is used to update state…

const [count, dispatch] = useReducer(reducer, 0);

Even though you would NOT normally do this, it’s (kind of) similar to doing this….

const [count, setCount] = useReducer(reducer, 0);

...
...

const increment = function () {
setCount(1);
};

So now, going back to the useReducer hook, we’re now passing in the dispatch(1) function, and the initial state (0) into useReducer, and from there, the “reducer” function is being called, which in this example will console log the initial state value and the action (dispatch(1)) which means in the console we’ll see two values…

function reducer(state, action) {
console.log(state, action);
}

The values in the console log will be:

0 1

The 0 is the initial state, and the 1 is the value passed in from dispatch(1)

Now the console log isn’t generally useful here unless it’s for debugging, so a better use might be something like this….

function reducer(state, action) {
return state + action;
}

So this function will return 0 (state) + 1 (action) which of course equals 1.

When we added our dispatch function, we added 1, which was for our button to increase the value by 1…

const increment = function () {
dispatch(1);
};

We could also add something similar to a decrement button, only this time, we might want to reduce the value by 1:

const decrement = function () {
dispatch(-1);
};

So now when you pass this to our reducer function, the result would be 0 minus 1, resulting in -1

function reducer(state, action) {
return state + action;
}

Up till now, all of this could easily be achieved (with less code!) by using the useState() hook to set state, but when things get a little more complicated, you start to see the benefits of useReducer.

In the example above we passed in a single value into the dispatch function…

const decrement = function () {
dispatch(-1);
};

Now whilst you can pass in single values into your dispatch function, what you’re more likely to do is pass in an object.

When you pass in an object, you’ll typically define a “type” which describes the action, and an optional “payload” which actually does what you want it to do. So based on the example above, you could do something like this….

const decrement = function () {
dispatch({type: "dec", payload: -1});
};

const increment = function () {
dispatch({type: "inc", payload: 1});
};

And now in our reducer function, we could account for these different options….

function reducer(state, action) {
if (action.type == "dec") {
return state + action.payload;
}
if (action.type == "inc") {
return state + action.payload;
}
}

We don’t necessarily even have to send a payload…. we could define the payload values in the reducer function itself. For example:

const decrement = function () {
dispatch({type: "dec"});
};

const increment = function () {
dispatch({type: "inc"});
};

So we’re saying, run the “dec” action when the user clicks on the button and the decrement event handler runs, and run the “inc” action when the user clicks on the button and the increment event handler runs.

This might be how our reducer function looks now….

function reducer(state, action) {
if (action.type == "dec") {
return state + 1;
}
if (action.type == "inc") {
return state - 1;
}
}

Getting more complex

So up until now, we’ve just been manipulating one piece of state, which is “count” but if all we need to do is manage a single piece of state, then it’s almost always easier to just use the useState() hook. So let’s take a look for situations, where we might want to mange more than one piece of state.

Let’s say we want to also manage a piece of state called “step”. Remember that we can also pass in an object to our useReducer() hook, not just a single value. That object can represent multiple pieces of state….

const initialState = {count: 0, step: 5};

const [state, dispatch] = useReducer(reducer, initialState);

const {count, step} = state;

So here we’ve passed in multiple pieces of state into useReducer, which represents “state”, and then we’ve simply de-structured that piece of state back to the individual pieces of state.

Now in terms of the reducer function, we previoulsy used two if statements…. if “inc” and if “dec” — This is fine for a small amount of action types, but on most projects where there are many (say more than 5) action types, most developers will use a switch statement instead.

So this….

function reducer(state, action) {
if (action.type == "dec") {
return state + 1;
}
if (action.type == "inc") {
return state - 1;
}
}

becomes this…..

function reducer(state, action) {
switch (action.type) {
case "dec":
return {...state, count: state.count - 1};
case "inc":
return {...state, count: state.count + 1};
default:
throw new Error("action not recognized");
}

So what we’re doing here is defining each case, and then spreading out the entire state object (containing two pieces of state) and then updating the count state.

The same applies if we wanted to update the “step” state. We do the same thing in the same switch statement….

function reducer(state, action) {
switch (action.type) {
case "dec":
return {...state, count: state.count - 1};
case "inc":
return {...state, count: state.count + 1};

case "decStep":
return {...state, step: state.step -1};
case "decStep":
return {...state, step: state.step + 1};

default:
throw new Error("action not recognized");
}

Here’s an example of a real-world piece of code that includes a reducer function with multiple switch/case statements…

const SECONDS_PER_QUESTION = 15;

const initialState = {
questions: [],

// "loading", "error", "ready", "active", "finished"
status: "loading",
index: 0,
answer: null,
points: 0,
highScore: 0,
secondsRemaining: null,
};

function reducer(state, action) {
switch (action.type) {
case "DATA_RECEIVED":
return { ...state, questions: action.payload, status: "ready" };
case "DATA_FAILED":
return { ...state, status: "error" };
case "START":
return {
...state,
status: "active",
secondsRemaining: state.questions.length * SECONDS_PER_QUESTION,
};
case "ANSWER":
const question = state.questions[state.index];
return {
...state,
answer: action.payload,
points:
action.payload === question.correctOption
? state.points + question.points
: state.points,
};
case "NEXT":
return {
...state,
index: state.index + 1,
answer: null,
};
case "FINISH":
return {
...state,
status: "finished",
highScore:
state.points > state.highScore ? state.points : state.highScore,
};
case "RESTART":
return { ...initialState, questions: state.questions, status: "ready" };
case "TIMER_TICK":
return {
...state,
secondsRemaining: state.secondsRemaining - 1,
status: state.secondsRemaining === 0 ? "finished" : state.status,
};
default:
throw new Error("Action not recognized");
}
}

function App() {
const [
{ questions, status, index, answer, points, highScore, secondsRemaining },
dispatch,
] = useReducer(reducer, initialState);

based on the action “type” received, the reducer function is able to determine what to do with state.

The key takeaway

The key takeaway here is that we have a way of manipulating multiple pieces of state by dispatching actions, and that we also have a nice centralized place to see what’s going on with our pieces of state, all in a single reducer function, by using a long list of switch/case statements.

The reducer function is also very useful when you want multiple pieces of state to all be updated at the same times, such as starting a game, where a timer needs to be reset, a score needs to be reset, a component needs to be restarted, etc.

The “dispatch” function is what we use to trigger these updates in the reducer function.

--

--

Jeff P

I tend to write about anything I find interesting. There’s not much more to it than that really :-)