skies.dev

Refactoring State Machines from useEffect to useReducer

6 min read

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 out
  • REST 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.

(clap if you liked the article)

You might also like