Learning React & Redux in 2023 — Part 16
To understand useReducer, and compare and contrast it against useEffect, we’ll create a new component for our project called Counter.
First we’ll setup as before.
We begin with a Counter.js component:
function Counter() {
return <div>Counter</div>;
}
export default Counter;
Next we’ll create a CounterPage.js file
import Counter from '../components/Counter';
function CounterPage() {
return (
<div>
<Counter />
</div>
);
}
export default CounterPage;
Now we’’ll update App.js to be aware of the component:
import Sidebar from "./components/Sidebar";
// import Link from "./components/Link";
import Route from "./components/Route";
import AccordionPage from "./pages/AccordionPage";
import ButtonPage from "./pages/ButtonPage";
import DropdownPage from "./pages/DropdownPage";
import ModalPage from "./pages/ModalPage";
import TablePage from "./pages/TablePage";
import CounterPage from "./pages/CounterPage";
function App() {
return (
<div className='container mx-auto grid grid-cols-6 gap-8 mt-4'>
<div>
<Sidebar />
</div>
<div className='col-span-5 relative'>
<Route path="accordion">
<AccordionPage />
</Route>
<Route path="button">
<ButtonPage />
</Route>
<Route path="dropdown">
<DropdownPage />
</Route>
<Route path="modal">
<ModalPage />
</Route>
<Route path="table">
<TablePage />
</Route>
<Route path="counter">
<CounterPage />
</Route>
</div>
</div>
);
}
export default App;
and finally update our Sidebar component as well:
import Link from "./Link";
function Sidebar() {
const links = [
{ path: "accordion", label: "Accordion" },
{ path: "button", label: "Buttons" },
{ path: "dropdown", label: "Dropdown" },
{ path: "modal", label: "Modal" },
{ path: "table", label: "Table" },
{ path: "counter", label: "Counter" },
];
const renderedLinks = links.map((link) => {
return ( <Link className='mb-3'
key={link.label}
to={link.path}
activeClassName='font-fold border-l-4 border-blue-700 pl-2'
>
{link.label}</Link> );
});
return (
<div className="sticky top-0 flex flex-col">
{renderedLinks}
</div>
);
}
export default Sidebar;
If all went well we should end up with something like this:
Our counter will have an initial counter value displayed on screen, and there will also be a form field to type in a value and then a button to add this to the initial count. Let’s set up our CounterPage file…
import { useState } from 'react';
import Button from '../components/Button';
import Counter from '../components/Counter';
import Panel from '../components/Panel';
function CounterPage() {
const [count, setCount] = useState(10);
const increment = () => {
setCount(count + 1);
};
const decrement = () => {
setCount(count - 1);
};
return (
<Panel>
<div>
<h1>Count is: {count}</h1>
<Button onClick={increment}>Increment</Button>
<Button onClick={decrement}>Decrement</Button>
</div>
<div>
<form>
<label>Add value to counter</label>
<input className="p-1 m-3 bg-gray-50 border-gray-500" type="number" />
<Button>Add it</Button>
</form>
</div>
</Panel>
);
}
export default CounterPage;
So we’ve added a couple of buttons on screen, both with event handlers. The first will increment the counter value by 1 and the second will subtract by 1.
Next we added a form, with a label, an input that accepts numbers, and a button.
Next we’ll add an event handler to this form, and an event handler for when the input field changes:
const handleChange = (event) => {
let value = parseInt(event.target.value) || 0;
setValueToAdd(value);
};
const handleSubmit = (event) => {
event.preventDefault();
setCount(count + valueToAdd);
setValueToAdd(0);
};
return (
<Panel>
<div>
<h1>Count is: {count}</h1>
<Button onClick={increment}>Increment</Button>
<Button onClick={decrement}>Decrement</Button>
</div>
<div>
<form onSubmit={handleSubmit}>
<label>Add value to counter</label>
<input value={valueToAdd || ""} onChange={handleChange} className="p-1 m-3 bg-gray-50 border-gray-500" type="number" />
<Button>Add it</Button>
</form>
</div>
</Panel>
);
So we have a handleChange event handler for the input field. Our event handler assigns the number value of event.target.value (event.target.value is handled as a string by default) and pass this into the Javascript parseInt() function which returns a number from the string output of event.target.value. We then assign this to “value”.
Note that we used this:
let value = parseInt(event.target.value) || 0;
What we’re saying is that if event.target.value is empty or NaN, then return 0 instead, because the || expression returns the first truthy value or the last falsey, so if event.target.value is not a truthy value, then it will return 0.
This value is then used to update our piece of state valueToAdd. As this value matches whatever was typed in (as confirmed from event.target.default) it can also be passed back as the live value into the input field.
Note that we used value={valueToAdd || “”}
This basically means that we take the first “truthy” value or the last falsely value. If we’ve typed in a number then valueToAdd will be truthy…. if not, we’ll just ensure the input field is blank. Without this, the input field will display a 0 to begin with.
We also handled to form submission using handleSubmit…
const handleSubmit = (event) => {
event.preventDefault();
setCount(count + valueToAdd);
setValueToAdd(0);
};
So we’re preventing the page from refreshing when we click the form button. We’re also updating the count piece of state to be the previous value plus the valueToAdd in the form field. Finally, we’re resetting the value in the form to be 0, which of course won’t be displayed.
useState vs useReducer
useReducer is a good candidate when you have two pieces of closely-related state, and also when a piece of state being updated, depeneds on what the current state value already is.
This is an example, of two closely-related pieces of state:
setCount(count + valueToAdd);
This is an example of a piece of state update being dependent on the current piece of state value
setCount(count + 1);
There are definitely some common parts between useState and useReducer
when we use the useState option, we create as a second part, the name of the piece of state, with “use” at the front to be able to change the piece of state. So for example, we have a piece of state called count, so we’ll be able to change our piece of state with setCount()
With useReducer, we always change state with “dispatch”.
When we use UseState, we generally define seperate individual variables for each piece of state within a single component.
In contrast, with useReducer, we group the state variables together in a single object, known simply as “state”
so if we wanted to refer to count, it would be state.count, and if we wanted to refer to valueToAdd, then it would be state.valueToAdd
When we call dispatch to update state, we can pass in 0 or 1 arguments. Community convention is to pass in an object with two keys —
type and payload
type is a string that describes what is being changed, and payload is the actual value being changed.
These are then passed into the reducer function as the SECOND argument, with the first argument, being the current state of the object. These are known as “action” objects.
this way, the reducer function knows which piece of state to focus on, as it can match the type string with an if statement or something else. for example:
so if action.type === increment-count, then it knows to update state.count, else if action.type === change value, then it knows to update state.valueToAdd
Note that we always spread out the existing state object (to make a copy) and then update that, rather than directly modifying state. As was the case with useState, we should NEVER modify state directly!
We also then include a “catch-all” of return state at the bottom, to ensure that if an if statement doesn’t match, then we’ll return back the state object, rather than “undefined”.
Let’s re-factor the code to now use useReducer instead of useState.
Firstly, we’ll change the import statement:
import { useReducer } from 'react';
Now we’ll create a reducer function that will handle the various changes:
function reducer (state, action) {
if (action.type === 'increment') {
return {...state,
count: state.count + 1
};
};
if (action.type === 'decrement') {
return {...state,
count: state.count - 1
};
};
if (action.type === 'value-to-add') {
return {
...state,
valueToAdd: action.payload,
};
};
if (action.type === 'add-it') {
return {
...state,
count: state.count + state.valueToAdd,
valueToAdd: 0,
};
};
return state;
}
We’ll define our initial state:
const [state, dispatch] = useReducer(reducer, {
count: 10,
valueToAdd: 0
});
We’ll then update our event handlers:
const increment = () => {
dispatch({ type: 'increment' });
};
const decrement = () => {
dispatch({ type: 'decrement' });
};
const handleChange = (event) => {
let value = parseInt(event.target.value) || 0;
dispatch({
type: 'value-to-add',
payload: value
});
};
const handleSubmit = (event) => {
event.preventDefault();
dispatch({
type: 'add-it',
});
};
And then we finally update our return statement:
return (
<Panel>
<div>
<h1>Count is: {state.count}</h1>
<Button onClick={increment}>Increment</Button>
<Button onClick={decrement}>Decrement</Button>
</div>
<div>
<form onSubmit={handleSubmit}>
<label>Add value to counter</label>
<input value={state.valueToAdd || ""} onChange={handleChange} className="p-1 m-3 bg-gray-50 border-gray-500" type="number" />
<Button>Add it</Button>
</form>
</div>
</Panel>
);
The entire code is as follows:
import { useReducer } from 'react';
import Button from '../components/Button';
// import Counter from '../components/Counter';
import Panel from '../components/Panel';
function reducer (state, action) {
if (action.type === 'increment') {
return {...state,
count: state.count + 1
};
};
if (action.type === 'decrement') {
return {...state,
count: state.count - 1
};
};
if (action.type === 'value-to-add') {
return {
...state,
valueToAdd: action.payload,
};
};
if (action.type === 'add-it') {
return {
...state,
count: state.count + state.valueToAdd,
valueToAdd: 0,
};
};
return state;
}
function CounterPage() {
const [state, dispatch] = useReducer(reducer, {
count: 10,
valueToAdd: 0
});
const increment = () => {
dispatch({ type: 'increment' });
};
const decrement = () => {
dispatch({ type: 'decrement' });
};
const handleChange = (event) => {
let value = parseInt(event.target.value) || 0;
dispatch({
type: 'value-to-add',
payload: value
});
};
const handleSubmit = (event) => {
event.preventDefault();
dispatch({
type: 'add-it',
});
};
return (
<Panel>
<div>
<h1>Count is: {state.count}</h1>
<Button onClick={increment}>Increment</Button>
<Button onClick={decrement}>Decrement</Button>
</div>
<div>
<form onSubmit={handleSubmit}>
<label>Add value to counter</label>
<input value={state.valueToAdd || ""} onChange={handleChange} className="p-1 m-3 bg-gray-50 border-gray-500" type="number" />
<Button>Add it</Button>
</form>
</div>
</Panel>
);
}
export default CounterPage;
The React community direction is to specify UPPERCASE contstant types instead of strings for matching, as these will assist with troubleshooting. A simple re-factor would be as follows:
import { useReducer } from 'react';
import Button from '../components/Button';
// import Counter from '../components/Counter';
import Panel from '../components/Panel';
const INCREMENT = 'increment';
const DECREMENT = 'decrement';
const VALUE_TO_ADD = 'value-to-add';
const ADD_IT = 'add-it';
function reducer (state, action) {
if (action.type === INCREMENT) {
return {...state,
count: state.count + 1
};
};
if (action.type === DECREMENT) {
return {...state,
count: state.count - 1
};
};
if (action.type === VALUE_TO_ADD) {
return {
...state,
valueToAdd: action.payload,
};
};
if (action.type === ADD_IT) {
return {
...state,
count: state.count + state.valueToAdd,
valueToAdd: 0,
};
};
return state;
}
function CounterPage() {
const [state, dispatch] = useReducer(reducer, {
count: 10,
valueToAdd: 0
});
const increment = () => {
dispatch({ type: INCREMENT });
};
const decrement = () => {
dispatch({ type: DECREMENT });
};
const handleChange = (event) => {
let value = parseInt(event.target.value) || 0;
dispatch({
type: VALUE_TO_ADD,
payload: value
});
};
const handleSubmit = (event) => {
event.preventDefault();
dispatch({
type: ADD_IT,
});
};
return (
<Panel>
<div>
<h1>Count is: {state.count}</h1>
<Button onClick={increment}>Increment</Button>
<Button onClick={decrement}>Decrement</Button>
</div>
<div>
<form onSubmit={handleSubmit}>
<label>Add value to counter</label>
<input value={state.valueToAdd || ""} onChange={handleChange} className="p-1 m-3 bg-gray-50 border-gray-500" type="number" />
<Button>Add it</Button>
</form>
</div>
</Panel>
);
}
export default CounterPage;
Another way of re-working this would be to use switches instead of if statements:
so this…
function reducer (state, action) {
if (action.type === INCREMENT) {
return {...state,
count: state.count + 1
};
};
if (action.type === DECREMENT) {
return {...state,
count: state.count - 1
};
};
if (action.type === VALUE_TO_ADD) {
return {
...state,
valueToAdd: action.payload,
};
};
if (action.type === ADD_IT) {
return {
...state,
count: state.count + state.valueToAdd,
valueToAdd: 0,
};
};
return state;
}
becomes this….
function reducer (state, action) {
switch (action.type) {
case INCREMENT:
return {...state,
count: state.count + 1
};
case DECREMENT:
return {...state,
count: state.count - 1
};
case VALUE_TO_ADD:
return {
...state,
valueToAdd: action.payload,
};
case ADD_IT:
return {
...state,
count: state.count + state.valueToAdd,
valueToAdd: 0,
};
default:
return state;
}
}
Keeping dispatches simple
If we look at our handleSubmit function, our dispatch was very simplistic…. we simply specified the type (ADD_IT) and then left all the actual logic to be handled by the reducer:
const handleSubmit = (event) => {
event.preventDefault();
dispatch({
type: ADD_IT,
});
};
switch (action.type) {
...
case ADD_IT:
return {
...state,
count: state.count + state.valueToAdd,
valueToAdd: 0,
};
default:
return state;
}
the addition of state.count and state.valueToAdd was made in the reducer rather than at the dispatch. Technically we could’ve made the addition at the dispatch, and simplified the reducer, but the argument against this approach is as follows:
Immer
Immer allows us to directly modify state in reducers, which is usually a no-no.
Whilst it is easier to use, not all projects will make advantage of it, so it’s important to still understand the process described above.