[React] Basics of Reducers
App Overview
Features
- Refactor counter component with
useReducer
. - Goal is very similar to usestate.
- It creates some state inside of a component.
- Whenever that state changes the component is going to rerender.
- Think of this hook as being kind of similar to useState.
- Compare
useState
anduseReducer
. - Learn community convention of
useReducer
design patterns.
Library
immer
Counter Component Design
Common Structure of Counter
Initial boilerplate of CounterPage.js
- Update when the form is submitted.
- Set input value to 0.
- Add to the count value.
-
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// "./pages/CounterPage.js" import { useState } from "react"; import Button from "../components/Button"; import Panel from "../components/Panel"; const CounterPage = ({ initialCount }) => { const [count, setCount] = useState(initialCount); const increment = () => { setCount(count + 1); } const decrement = () => { setCount(count - 1); } return ( <Panel className="m-3"> <h1 className="text-lg">Count is {count}</h1> <div className="flex flex-row"> <Button onClick={increment} >Increment</Button> <Button onClick={decrement} >Decrement</Button> </div> <form> <label>Add a lot!</label> <input type="number" className="p-1 m-3 bg-gray-50 border border-gray-300" /> <Button>Add it!</Button> </form> </Panel> ); }; export default CounterPage;
Change Input Value Type
Input element receives data as a string.
event.target.value
is a string.- Use
parseInt
to change the value into number type. - When input text is removed, change the input value to 0.
- Otherwise the value will be
NaN
.
- Otherwise the value will be
- Use
const value = parseInt(event.target.value) || 0
0 Keeps Showing Up
- Input value cannot be removed since
valueToAdd
is 0 by default. - Conditionally render when
valueToAdd
is not zero. -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// "./pages/CounterPage.js" //... const CounterPage = ({ initialCount }) => { const [count, setCount] = useState(initialCount); const [valueToAdd, setValueToAdd] = useState(0); //... const handleChange = (e) => { const value = parseInt(e.target.value) || 0; setValueToAdd(value); } const handleSubmit = (e) => { e.preventDefault(); setCount(count + valueToAdd); setValueToAdd(0); } return ( <Panel className="m-3"> /* ... */ <form onSubmit={handleSubmit}> <label>Add a lot!</label> <input value={valueToAdd || ""} onChange={handleChange} type="number" className="p-1 m-3 bg-gray-50 border border-gray-300" /> <Button>Add it!</Button> </form> </Panel> ); }; export default CounterPage;
From useState
To useReducer
useState
- Absolutely fine hook to use whenever a component needs state.
useReducer
- Alternative to
useState
.- Produces state.
- Changing this state makes component rerender.
- Useful when you have several different closely-related pieces of state.
- Useful when future state values depend on the current state.
Compare useState
and useReducer
Both useState
and useReducer
share three concepts.
- State variable.
- Function to change state.
- Initial value for this state.
Different Community Convention
- When using
useState
, each piece of state is defined as a separate variable. - On the other hand, a single object manages the whole state when using
useReducer
.- All states for the whole component should be defined in the single object.
state.count
instead ofcount
.state.valueToAdd
instead ofvalueToAdd
.
- Debugging gets much easier.
- Simply
console.log(state);
instead ofconsole.log(var1, var2, ...);
.
- Simply
-
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// "./pages/CounterPage.js" import { useState, useReducer } from "react"; //... const reducer = (state, action) => { ; } const CounterPage = ({ initialCount }) => { // const [count, setCount] = useState(initialCount); // const [valueToAdd, setValueToAdd] = useState(0); const [state, dispatch] = useReducer(reducer, { count: initialCount, valueToAdd: 0 }); //... }; export default CounterPage;
State Updates with useReducer
When you call dispatch
, react is gonna go and find reducer
function and run it.
- The first argument of
reducer
is current state that is being maintained by reducer. - The second argument of
reducer
is an action object, and you can pass whatever you want throughdispatch
.- More than one element in
dispatch
are ignored. - The argument passed to
dispatch
shows up inreducer
as the second argument,action
.
- More than one element in
- Whatever the
reducer
returns is the new state. -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// "./pages/CounterPage.js" //... const reducer = (state, action) => { return { ...state, count: state.count + 1 } } const CounterPage = ({ initialCount }) => { //... const increment = () => { // setCount(count + 1); dispatch(); } //... }; export default CounterPage;
Rules around Reducer Functions
- Whatever
reducer
returns will be your new state. - If
reducer
returns nothing, then your state will beundefined
! - No async/await, no requests, no promises, no outside variables.
- Like almost everywhere else in React, don’t directly modify the state object!
Understanding Action Objects
- When we call
dispatch()
, we need to pass along some information to tell the reducer how the state should be updated. - The React community has come up with a convention.
- This is a very common commounity convention, not a requirement.
Action Object Convention
When we need to modify state, we will call dispatch
and always pass in an action
object.
- The
action
object will always have a string property calledtype
. This tells the reducer what state update it needs to make. - If we need to communicate some data to the reducer, it will be placed on the
payload
property of theaction
object. -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
// "./pages/CounterPage.js" //... const reducer = (state, action) => { switch (action.type) { case "increment": return { ...state, count: state.count + 1 }; case "change-value-to-add": return { ...state, valueToAdd: action.payload, } default: return state; } }; const CounterPage = ({ initialCount }) => { const increment = () => { dispatch({ type: "increment" }); }; //... const handleChange = (e) => { const value = parseInt(e.target.value) || 0; dispatch({ type: "change-value-to-add", payload: value }); }; //... }; export default CounterPage;
Const Action Types
String variables are prone to make typo mistakes.
- Create string variables with value of action types.
- Same thing as writing the string out directly, but prevents typos.
- The ALL_CAPS is another community convention.
- Tells other engineers that this is an action type.
-
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// "./pages/CounterPage.js" //... const INCREMENT_COUNT = "increment"; const SET_VALUE_TO_ADD = "set-value-to-add"; const reducer = (state, action) => { switch (action.type) { case INCREMENT_COUNT: //... case SET_VALUE_TO_ADD: //... default: return state; } }; const CounterPage = ({ initialCount }) => { //... const increment = () => { // setCount(count + 1); dispatch({ type: INCREMENT_COUNT }); }; //... const handleChange = (e) => { const value = parseInt(e.target.value) || 0; dispatch({ type: SET_VALUE_TO_ADD, payload: value }); }; //... }; export default CounterPage;
Refactoring to Switch
In default case, some people throw an error.
- Running on default case is a sign of doing something that you did not expect.
-
1 2 3 4 5 6 7 8 9 10
// "./pages/CounterPage.js" //... const reducer = (state, action) => { //... default: throw new Error("Unexpected action type: " + action.type); }; }; //... export default CounterPage;
Adding New State Updates
Follow three steps when you need a new way to update state.
- Add a new constant action type string.
- Add a call to
dispatch
. - Add a new case statement in your reducer.
-
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
// "./pages/CounterPage.js" //... const reducer = (state, action) => { switch (action.type) { //... case ADD_VALUE_TO_COUNT: return { ...state, count: state.count + state.valueToAdd, valueToAdd: 0 } //... }; }; const CounterPage = ({ initialCount }) => { //... const handleSubmit = (e) => { e.preventDefault(); dispatch({ type: ADD_VALUE_TO_COUNT, payload: 0 }) }; //... }; export default CounterPage;
A Few Design Considerations Around Reducers
Purpose of ...state
In ADD_VALUE_TO_COUNT
, both count
and valueToAdd
variables are updated.
- Then what is the point of
...state
? - Some times later, state object will be changed and additional feature will have been added.
Desigh of Action Object
It is recommend to make a logic in the reducer function and keep the dispatches simple.
- Otherwise, you have to write a lot of logic at the location where we dispatch.
- It is very easy to make mistakes.
- Less duplicated code if you need to dispatch the same action in multiple places.
- Part of the goal of reducers is to have a very specific set of ways that state can be changed.
Introducing Immer
1
2
3
case INCREMENT_COUNT:
state.count = state.count + 1;
return;
Immer
library allows us to directly modify the state.
- You can directly mutate state.
- Normally reducer must return a new object so that React rerenders components.
- Directly changing state is not allowed.
- Do not have to return a new value.
- Still needs
return
statement in each case, otherwise you get fallthrough.
Immer in Action
- Import
produce
fromimmer
. - Wrap
reducer
function withprovide
. -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// "./pages/CounterPage.js" import produce from "immer"; //... const reducer = (state, action) => { switch (action.type) { case INCREMENT_COUNT: state.count++; return; case DECREMENT_COUNT: state.count--; return; case SET_VALUE_TO_ADD: state.valueToAdd = action.payload; return; case ADD_VALUE_TO_COUNT: state.count += state.valueToAdd; state.valueToAdd = 0; return; default: throw new Error("Unexpected action type: " + action.type); }; }; const CounterPage = ({ initialCount }) => { const [state, dispatch] = useReducer(produce(reducer), { count: initialCount, valueToAdd: 0 }); //... }; export default CounterPage;
Leave a comment