React’s useState
hook can get you a really long way with React state
management. That said, sometimes you want to separate the state logic from the
components that make the state changes. In addition, if you have multiple
elements of state that typically change together, then having an object that
contains those elements of state can be quite helpful.
This is where useReducer
comes in really handy.
This exercise will take you pretty deep into useReducer
. Typically, you’ll use
useReducer
with an object of state, but we’re going to start by managing a
single number (a count
). We’re doing this to ease you into useReducer
and
help you learn the difference between the convention and the actual API.
Here’s an example of using useReducer
to manage the value of a name in an
input.
function nameReducer(previousName: string, newName: string) {return newName}const initialNameValue = 'Joe'function NameInput() {const [name, setName] = useReducer(nameReducer, initialNameValue)const handleChange = (event) => setName(event.currentTarget.value)return (<div><label>Name: <input defaultValue={name} onChange={handleChange} /></label><div>You typed: {name}</div></div>)}
One important thing to note here is that the reducer (called nameReducer
above) is called with two arguments:
setName
above) is called
with. This is often called an “action.”📜 Here are two really helpful blog posts comparing useState
and useReducer
:
👨💼 Back in the day, we had this.setState
from class components? We’re going to
make the state updater (dispatch
function) behave in a similar way by changing
our state
to an object ({count: 0}
) and then calling the state updater with
an object which merges with the current state.
So here’s how I want things to look now:
const [state, setState] = useReducer(countReducer, {count: initialCount,})const { count } = stateconst increment = () => setState({ count: count + step })const decrement = () => setState({ count: count - step })
How would you need to change the reducer to make this work?
How would you make it support multiple state properties? For example:
const [state, setState] = useReducer(countReducer, {count: initialCount,someOtherState: 'hello',})const { count } = stateconst increment = () => setState({ count: count + step })const decrement = () => setState({ count: count - step })
Calling increment
or decrement
in this case should only update the count
property and leave the someOtherState
property alone. So the setState
function should merge the new state with the old state.
👨💼 this.setState
from class components can also accept a function. So let’s
add support for that with our simulated setState
function. See if you can
figure out how to make your reducer support both the object as in the last step
as well as a function callback:
const [state, setState] = useReducer(countReducer, {count: initialCount,})const { count } = stateconst increment = () =>setState((currentState) => ({ count: currentState.count + step }))const decrement = () =>setState((currentState) => ({ count: currentState.count - step }))
👨💼 Now it’s time to get to the actual convention for reducers in React apps.
Update your reducer so I can do this:
const [state, dispatch] = useReducer(countReducer, {count: initialCount,})const { count } = stateconst increment = () => dispatch({ type: 'INCREMENT', step })const decrement = () => dispatch({ type: 'DECREMENT', step })
The key here is that the logic for updating the state is now in the reducer, and the component is just dispatching actions. This actually gives us a bit of a declarative API for updating state, which is nice. The component is just saying what it wants to happen, and the reducer is the one that decides how to make it happen.
👨💼 Let’s try our hand at using useReducer
for something a little more real.
We’ll be refactoring the useState
out of our tic-tac-toe game to use
useReducer
instead.
Your reducer should enable the following actions:
type GameAction =| { type: 'SELECT_SQUARE'; index: number }| { type: 'SELECT_STEP'; step: number }| { type: 'RESTART' }function gameReducer(state: GameState, action: GameAction) {// your code...}
Note that to do the lazy state initialization we need to provide three arguments
to useReducer
. Here’s an example for a count reducer:
// ...function getInitialState(initialCount: number) {return { count: initialCount }}function Counter() {const [count, dispatch] = useReducer(countReducer,props.initialCount,getInitialState,)// ...}
Notice that the getInitialState
function is called only once, when the
component is first rendered and it’s called with the initialCount
prop which
is passed to the useReducer
hook as the second argument.
If you don’t need an argument to your initial state callback, you can just pass
null
.
Good luck!
import { useEffect, useReducer } from 'react'import * as ReactDOM from 'react-dom/client'import {calculateNextValue,calculateStatus,calculateWinner,isValidGameState,type GameState,type Squares,} from '#shared/tic-tac-toe-utils'function Board({squares,onClick,}: {squares: SquaresonClick: (index: number) => void}) {function renderSquare(i: number) {const value = squares[i]const label = value ? `square ${i}, ${value}` : `square ${i} empty`return (<button className="square" onClick={() => onClick(i)} aria-label={label}>{squares[i]}</button>)}return (<div><div className="board-row">{renderSquare(0)}{renderSquare(1)}{renderSquare(2)}</div><div className="board-row">{renderSquare(3)}{renderSquare(4)}{renderSquare(5)}</div><div className="board-row">{renderSquare(6)}{renderSquare(7)}{renderSquare(8)}</div></div>)}const defaultState: GameState = {history: [Array(9).fill(null)],currentStep: 0,}const localStorageKey = 'tic-tac-toe'function getInitialGameState() {let localStorageValuetry {localStorageValue = JSON.parse(window.localStorage.getItem(localStorageKey) ?? 'null',)} catch {// something is wrong in localStorage, so don't use it}return isValidGameState(localStorageValue) ? localStorageValue : defaultState}type GameAction =| { type: 'SELECT_SQUARE'; index: number }| { type: 'SELECT_STEP'; step: number }| { type: 'RESTART' }function gameStateReducer(state: GameState, action: GameAction) {switch (action.type) {case 'SELECT_SQUARE': {const { currentStep, history } = stateconst newHistory = history.slice(0, currentStep + 1)const currentSquares = history[currentStep]const winner = calculateWinner(currentSquares)if (winner || currentSquares[action.index]) return stateconst squares = currentSquares.with(action.index,calculateNextValue(history[currentStep]),)return {history: [...newHistory, squares],currentStep: newHistory.length,}}case 'SELECT_STEP': {return { ...state, currentStep: action.step }}case 'RESTART': {return defaultState}default:throw new Error(`Unhandled action type: ${action}`)}}function App() {const [state, dispatch] = useReducer(gameStateReducer,null,getInitialGameState,)const currentSquares = state.history[state.currentStep]const winner = calculateWinner(currentSquares)const nextValue = calculateNextValue(currentSquares)const status = calculateStatus(winner, currentSquares, nextValue)useEffect(() => {window.localStorage.setItem(localStorageKey, JSON.stringify(state))}, [state])function selectSquare(index: number) {dispatch({ type: 'SELECT_SQUARE', index })}function restart() {dispatch({ type: 'RESTART' })}const moves = state.history.map((_stepSquares, step) => {const desc = step ? `Go to move number ${step}` : 'Go to game start'const isCurrentStep = step === state.currentStepconst label = isCurrentStep ? `${desc} (current)` : desc// NOTE: the "step" is actually the "index" which normally you don't want to// use as the "key" prop. However, in this case, the index is effectively// the "id" of the step in history, so it is correct.return (<li key={step}><buttononClick={() =>// setState(previousState => ({ ...previousState, currentStep: step }))dispatch({ type: 'SELECT_STEP', step })}aria-disabled={isCurrentStep}aria-label={label}aria-current={isCurrentStep ? 'step' : undefined}>{desc} {isCurrentStep ? '(current)' : null}</button></li>)})return (<div className="game"><div className="game-board"><Board onClick={selectSquare} squares={currentSquares} /><button className="restart" onClick={restart}>restart</button></div><div className="game-info"><div aria-live="polite">{status}</div><ol>{moves}</ol></div></div>)}const rootEl = document.createElement('div')document.body.append(rootEl)ReactDOM.createRoot(rootEl).render(<App />)
.game {font:14px 'Century Gothic',Futura,sans-serif;margin: 20px;min-height: 260px;}.game ol,.game ul {padding-left: 30px;}.board-row:after {clear: both;content: '';display: table;}.status {margin-bottom: 10px;}.restart {margin-top: 10px;}.square {background: #fff;border: 1px solid #999;float: left;font-size: 24px;font-weight: bold;line-height: 34px;height: 34px;margin-right: -1px;margin-top: -1px;padding: 0;text-align: center;width: 34px;}.square:focus {outline: none;background: #ddd;}.game {display: flex;flex-direction: row;}.game-info {margin-left: 20px;min-width: 190px;}[aria-disabled='true'] {opacity: 0.6;pointer-events: none;user-select: none;}
Quick Links
Legal Stuff
Social Media