Refactoring State Machines from useEffect to useReducer
I just built WOD: a tabata and HIIT workout webapp.
The app is a state machine. When a workout is active, you can either be in a
WORK
state when you're actively working outREST
state when you're resting- or a
PREP
state when you're preparing for the next workout.
When I was hacking this project initially, I built the state machine logic
in the way I was familiar with: useEffect
hooks. For the sake of example,
here's a simplified verison of how the app's state logic was managed:
type AppState = 'prep' | 'work' | 'rest';
export function useAppState() {
const [state, setState] = React.useState<AppState>('prep');
const [timer, setTimer] = React.useState<number>(30);
// this effect handles state changes when timer reaches 0
React.useEffect(() => {
if (timer > 0) {
return;
}
switch (state) {
case 'prep': {
setState('work');
setTimer(20);
break;
}
case 'rest': {
setState('work');
setTimer(20);
break;
}
case 'work': {
setState('rest');
setTimer(10);
break;
}
}
}, [state, timer, setState, setTimer]);
// this effect handles the workout clock
React.useEffect(() => {
if (timer === 0) {
return;
}
const t = setInterval(() => {
setTimer(timer - 1);
});
return () => {
clearInterval(t);
};
}, [timer, setTimer]);
return state;
}
Of course, in the actual project, there was
over a dozen state variables controlled by useState
and the useEffect
dependency arrays were getting increasingly long.
The complexity grew and grew.
All that said, the app "worked", it just looked liked a spaghetti mess. Just in time, I happened to run across David K. Piano's tweet, which said:
useEffect tip: don't
This tweet (paired with React 18's controversial decision to
double-invoke useEffect
hooks in
Strict Mode)
got me thinking: Maybe it's time to refactor.
Refactoring from useEffect
to useReducer
I stumbled upon an excellent YouTube video on managing complex state with
React's useReducer
hook.
I was convinced. Piece by piece, I removed all the useState
and useEffect
complexities and broke it down using the reducer pattern.
The end result turned into something like this.
interface Engine {
state: 'prep' | 'work' | 'rest';
timer: number;
}
// ๐ก we use an object in case some actions require extra parameters
type ReducerAction =
| {type: 'prep'}
| {type: 'work'}
| {type: 'rest'}
| {type: 'tick'};
const reducer: React.Reducer<Engine, ReducerAction> = (prevState, action) => {
switch (action.type) {
case 'prep': {
return {
...prevState,
state: 'prep',
timer: 30,
};
}
case 'work': {
return {
...prevState,
state: 'work',
timer: 20,
};
}
case 'rest': {
return {
...prevState,
state: 'rest',
timer: 10,
};
}
case 'tick': {
const timer = prevState.timer - 1;
// timer reaching 0 indicates we entered a new state
if (timer === 0) {
switch (prevState.state) {
case 'prep':
return reducer(prevState, {type: 'work'});
case 'work':
return reducer(prevState, {type: 'rest'});
case 'rest':
return reducer(prevState, {type: 'work'});
default:
throw new Error('app state unknown');
}
}
// otherwise we decrement the timer with the current state
return {
...prevState,
timer,
};
}
default: {
throw new Error('dispatch action type unknown');
}
}
};
export function useAppState() {
const [state, dispatch] = React.useReducer(reducer, {
state: 'prep',
timer: 30,
});
// we still need an effect for the workout clock,
// but now the effect now dispatches an action to the reducer
//
// another benefit is now this effect will only re-run when dispatch
// changes (not very often). This is much better than re-running
// whenever the timer updates.
React.useEffect(() => {
const t = setInterval(() => {
dispatch({type: 'tick'});
});
return () => {
clearInterval(t);
};
}, [dispatch]);
return state;
}
At first glance, this may look more complex than the solution with useEffect
.
It's indeed more lines of code.
But remember the example we're looking at here is much simpler than my
actual implementation.
Recall previously we had over a dozen variables controlled by useState
and
several
useEffect
hooks that would cascade effects depending
on what was in their dependency array dependency arrays. For example:
// here's an example showing what I mean by "cascading effects"
function useContrivedExample() {
const [a, setA] = React.useState(0);
const [b, setB] = React.useState(1);
const [c, setC] = React.useState(2);
// effect A causes effect B to run
React.useEffect(() => {
setB(a);
}, [a, setB]);
// effect B causes effect C to run
React.useEffect(() => {
setC(b);
}, [b, setC]);
return a + b + c;
}
After all the effects run, a === b && a === c
. This is what I mean by "cascading effects".
The first effect causes the second one to run, and so on. Another consequence of
having cascading effects is this could cause your component to re-render multiple times.
A component using useContrivedExample
would re-render at least 3 times, once for each effect.
With the reducer pattern, our stage changes are encapsulated
in the reducer, and the rest of the app can update state declaratively
via the dispatch
function.
In the real project, I didn't have a giant function with each dispatch handler implementation inlined.
I extracted each branch of the switch
case into separate functions
and organized each function into separate files with the following file structure:
โโโ reducers
โย ย โโโ applyFilters.ts
โย ย โโโ done.ts
โย ย โโโ idle.ts
โย ย โโโ index.ts
โย ย โโโ nope.ts
โย ย โโโ prep.ts
โย ย โโโ rest.ts
โย ย โโโ skipWorkout.ts
โย ย โโโ tick.ts
โย ย โโโ toggleDisclaimer.ts
โย ย โโโ toggleInfo.ts
โย ย โโโ toggleMute.ts
โย ย โโโ togglePause.ts
โย ย โโโ toggleSettings.ts
โย ย โโโ work.ts
index.ts
is where I wrote out the reducer function with the switch
statement.
Depending on the case, the reducer would dispatch out to one of the other functions
isolated in its own file.
In my opinion, this made the state management code easy to reason about.
It looks something like this:
// index.ts
export const reducer: React.Reducer<Engine, ReducerAction> = (
prevState,
action,
) => {
// each action type dispatches into its own dispatch handler.
// this makes it easier to manage more complex dispatch logic
switch (action.type) {
case 'prep': {
return prep(prevState);
}
case 'work': {
return work(prevState);
}
case 'rest': {
return rest(prevState);
}
case 'tick': {
return tick(prevState);
}
// and so on...
}
};
Overall, I'm happy with the decision to move state management from useEffect
and useState
to useReducer
. I encourage you to try it out when you need to model state machines
or large, grouped sets of state.