HomeAbout Me

Advanced React APIs 1: Advanced State Management

By Daniel Nguyen
Published in React JS
May 21, 2025
2 min read
Advanced React APIs 1: Advanced State Management

Advanced State Management

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:

  1. the current state
  2. whatever it is that the dispatch function (called setName above) is called with. This is often called an “action.”

📜 Here are two really helpful blog posts comparing useState and useReducer:

  • Should I useState or useReducer?
  • How to implement useState with useReducer

State Object

👨‍💼 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 } = state
const 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 } = state
const 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.

Action Function

👨‍💼 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 } = state
const increment = () =>
setState((currentState) => ({ count: currentState.count + step }))
const decrement = () =>
setState((currentState) => ({ count: currentState.count - step }))

Traditional Reducer

👨‍💼 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 } = state
const 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.

Real World

👨‍💼 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: Squares
onClick: (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 localStorageValue
try {
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 } = state
const newHistory = history.slice(0, currentStep + 1)
const currentSquares = history[currentStep]
const winner = calculateWinner(currentSquares)
if (winner || currentSquares[action.index]) return state
const 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.currentStep
const 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}>
<button
onClick={() =>
// 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;
}

Tags

#React

Share

Previous Article
React Hook Section 6: Tic Tac Toe

Table Of Contents

1
Advanced State Management
2
State Object
3
Action Function
4
Traditional Reducer
5
Real World

Related Posts

React Testing 8: Testing custom hook
September 09, 2025
1 min
© 2025, All Rights Reserved.
Powered By

Quick Links

About Me

Legal Stuff

Social Media