State Machines in React

The "state machine" programming pattern is a great way to refactor code with lots of nested conditionals. For example, imagine you're writing a component that lets users review a stack of flash cards. The basic state for the component would be to track which card is the current card, and whether it's been flipped or not:

/**
 * A React component for rendering a single flash card at a time
 * from a list of card objects.
 */
const FlashCardReview = (props) => {
  /*
   * props.cardData is an array of objects, e.g.
   * [
   *   { id: 0, front: 'こんにちは', back: 'Good afternoon' },
   *   ...
   * ]
   */
  const { cardData } = props;

  const [currentCardIndex, setCurrentCardIndex] = React.useState(0);

  const [isRevealed, setIsRevealed] = React.useState(false);

  return (
    <FlashCard
      front={cardData[currentCardIndex].front}
      back={cardData[currentCardIndex].back}
      isRevealed={isRevealed}
      onClick={() => {
        if (!isRevealed) {
          setIsRevealed(true);
        } else {
          const nextCardIndex = currentCardIndex + 1;
          setCurrentCardIndex(nextCardIndex);
          setIsRevealed(false);
        }
      }}
    />
  );
};

Clicking on a card reveals its back side, then clicking again moves to the next card in the list.

So what happens when we run out of cards to review in the current stack? It would be nice to show a message in that case since we won't have data to render a <FlashCard>. Let's add a conditional to handle that situation.

  const [isRevealed, setIsRevealed] = React.useState(false);

+ if (currentCardIndex >= cardData.length) {
+   return 'Great job! You finished your current review session.';
+ }

  return (
    <FlashCard
      ...
    />
  );

What would it take to show a "welcome" state that displays the number of cards you're about to review, with a button to start the review? And why not take it one step further - let's also show a special message when you're resuming a review that you started in a previous session.

const FlashCardReview = (props) => {
  const { cardData } = props;

  // Copy cardData to internal state so we can shift cards off the top
  // as they're reviewed
  const [cardsToReview, setCardsToReview] = React.useState([...cardData]);

+ const [showWelcome, setShowWelcome] = React.useState(true);

- const [currentCardIndex, setCurrentCardIndex] = React.useState(0);
+ const [currentCardIndex, setCurrentCardIndex] = React.useState(() => {
+   const resumeFromCard = localStorage.getItem('resumeFromCard');
+   if (resumeFromCard) { // local storage only stores strings so convert back to number
+     const savedIndex = parseFloat(resumeFromCard);
+     return savedIndex;
+   }
+   return 0;
+ });

  const [isRevealed, setIsRevealed] = React.useState(false);

  if (currentCardIndex >= cardData.length) {
    return 'Great job! You finished your current review session.';
  }

+ if (showWelcome) {
+   if (currentCardIndex === 0) {
+     return (
+       <>
+         <span>{cardData.length} cards ready for review.</span>
+         <button onClick={() => setShowWelcome(false)}>
+           Start review
+         </button>
+       </>
+     );
+   }
+   return (
+     <>
+       <span>Welcome back! Let's pick up where you left off.</span>
+       <button onClick={() => setShowWelcome(false)}>
+         Start review
+       </button>
+     </>
+   );
+ }

  return (
    <FlashCard
      front={currentCard.front}
      back={currentCard.back}
      isRevealed={isRevealed}
      onClick={() => {
        if (!isRevealed) {
          setIsRevealed(true);
        } else {
          const nextCardIndex = currentCardIndex + 1;
          setCurrentCardIndex(nextCardIndex);
          setIsRevealed(false);
+         if (nextCardIndex < cardData.length) {
+           localStorage.setItem('resumeFromCard', nextCardIndex);
+         } else {
+           localStorage.removeItem('resumeFromCard');
+         }
        }
      }}
    />
  );

You can see I'm having to be pretty careful about the order of the conditionals. This code is getting brittle - each state is one accidental refactor away from leaking into another, because of the interconnection between scenarios.

As we add more and more scenarios like this, we'll end up with more and more checks up front to handle each possible combination and prevent state leaks.

Introducing the State Machine

There's a concept in theoretial computer science called a finite-state machine. It's meant to represent a limited model of computation, but in practice it captures our intuition about how lots of real systems change over time.

The textbook example is modeling a traffic intersection as a collection of states (lights in each direction) with only valid transitions between them (one-directional arrows):

The key here is noticing what's missing. Invalid states, like a green light in both directions simultaneously, aren't shown because those states are unreachable by any transition in the system. Notice too there's no need to check e.g. IF the main direction is green THEN set the cross to red, etc. The correct behavior of the lights is fully described only using a set of states and a set of transitions between those states.

Now let's take this model as inspiration for refactoring our React example.

The React State Machine

Our scenarios can be summarized as 4 distinct state machine states:

// Rough and ready plain JS enum, if this was Typescript you would
// use an actual `enum` type instead.
const ReviewState = {
  READY: 1,
  READY_CONTINUE: 2,
  STARTED: 3,
  FINISHED: 4,
};

There are many ways to implement the state machine pattern, including table-like data structures to represent transitions between states. But we don't need all of that for a React component - it's better to just use a switch for rendering each ReviewState, and different event listeners will transition between those states.

- const [showWelcome, setShowWelcome] = React.useState(true);
+ const [reviewState , setReviewState] = React.useState(() => {
+   const resumeFromCard = localStorage.getItem('resumeFromCard');
+   if (resumeFromCard) {
+     return ReviewState.READY_CONTINUE;
+   }
+   return ReviewState.READY;
+ });

  const [currentCardIndex, setCurrentCardIndex] = React.useState(() => {
    const resumeFromCard = localStorage.getItem('resumeFromCard');
    if (resumeFromCard) {
      // find the card associated with the stored index from the previous session
      const savedIndex = parseFloat(resumeFromCard);
      return savedIndex;
    }
    return 0;
  });

  const [isRevealed, setIsRevealed] = React.useState(false);

- if (currentCardIndex >= cardData.length) {
-   return 'Great job! You finished your current review session.';
- }
- if (showWelcome) {
-   if (currentCardIndex === 0) {
-     return (
-       <>
-         <span>{cardData.length} cards ready for review.</span>
-         <button onClick={() => setShowWelcome(false)}>
-           Start review
-         </button>
-       </>
-     );
-   }
-   return (
-     <>
-       <span>Welcome back! Let's pick up where you left off.</span>
-       <button onClick={() => setShowWelcome(false)}>
-         Start review
-       </button>
-     </>
-   );
- }
-
- return (
-   <FlashCard
-     front={cardData[currentCardIndex].front}
-     back={cardData[currentCardIndex].back}
-     isRevealed={isRevealed}
-     onClick={() => {
-       if (!isRevealed) {
-         setIsRevealed(true);
-       } else {
-         const nextCardIndex = currentCardIndex + 1;
-         setCurrentCardIndex(nextCardIndex);
-         setIsRevealed(false);
-
-         if (nextCardIndex < cardData.length) {
-           localStorage.setItem('resumeFromCard', nextCardIndex);
-         } else {
-           localStorage.removeItem('resumeFromCard');
-         }
-       }
-     }}
-   />
- );
+ switch (reviewState) {
+   default:
+   case ReviewState.READY:
+     return (
+       <>
+         <span>{cardData.length} cards ready for review.</span>
+         <button onClick={() => setReviewState(ReviewState.STARTED)}>
+           Start review
+         </button>
+       </>
+     );
+   case ReviewState.READY_CONTINUE:
+     return (
+       <>
+         <span>Welcome back! Let's pick up where you left off.</span>
+         <button onClick={() => setReviewState(ReviewState.STARTED)}>
+           Start review
+         </button>
+       </>
+     );
+   case ReviewState.STARTED:
+     return (
+       <FlashCard
+         front={cardData[currentCardIndex].front}
+         back={cardData[currentCardIndex].back}
+         isRevealed={isRevealed}
+         onClick={() => {
+           if (!isRevealed) {
+             setIsRevealed(true);
+           } else {
+             const nextCardIndex = currentCardIndex + 1;
+             setCurrentCardIndex(nextCardIndex);
+             setIsRevealed(false);
+             if (nextCardIndex < cardData.length) {
+               localStorage.setItem('resumeFromCard', nextCardIndex);
+             } else {
+               localStorage.removeItem('resumeFromCard');
+               setReviewState(ReviewState.FINISHED);
+             }
+           }
+         }}
+       />
+     );
+   case ReviewState.FINISHED:
+     return 'Great job! You finished your current review session.';
+ }

That's a big diff, so let's go line-by-line. First I'm removing the showWelcome boolean state, and adding a new state value reviewState that's only assigned values from our new ReviewState enum. reviewState will act as the current state of the state machine at all times.

Next I'm removing the conditionals for showing the different welcome / complete messages and replacing them with a single switch over every possible reviewState value.

Finally the event handlers were updated to call setReviewState(). Notice how the valid transitions between states are enforced by being part of separate switch cases - e.g. from the STARTED state the only valid transition is to FINISHED because there are no other calls to setReviewState() from that case.

Wrapping Up

Here's the state diagram for our refactored component:

And here's the complete code:

export const ReviewState = {
  READY: 1,
  READY_CONTINUE: 2,
  STARTED: 3,
  FINISHED: 4,
};

/**
 * A React component for rendering a single flash card at a time
 * from a list of card objects.
 */
const FlashCardReview = (props) => {
  /*
   * props.cardData is an array of objects, e.g.
   * [
   *   { id: 0, front: 'こんにちは', back: 'Good afternoon' },
   *   ...
   * ]
   */
  const { cardData } = props;

  const [reviewState, setReviewState] = React.useState(() => {
    const resumeFromCard = localStorage.getItem("resumeFromCard");
    if (resumeFromCard) {
      return ReviewState.READY_CONTINUE;
    }
    return ReviewState.READY;
  });

  const [currentCardIndex, setCurrentCardIndex] = React.useState(() => {
    const resumeFromCard = localStorage.getItem("resumeFromCard");
    if (resumeFromCard) {
      // find the card associated with the stored index from the previous session
      const savedIndex = parseFloat(resumeFromCard);
      return savedIndex;
    }
    return 0;
  });

  const [isRevealed, setIsRevealed] = React.useState(false);

  switch (reviewState) {
    default:
    case ReviewState.READY:
      return (
        <>
          <span>{cardData.length} cards ready for review.</span>
          <button onClick={() => setReviewState(ReviewState.STARTED)}>
            Start review
          </button>
        </>
      );
    case ReviewState.READY_CONTINUE:
      return (
        <>
          <span>Welcome back! Let's pick up where you left off.</span>
          <button onClick={() => setReviewState(ReviewState.STARTED)}>
            Start review
          </button>
        </>
      );
    case ReviewState.STARTED:
      return (
        <FlashCard
          front={cardData[currentCardIndex].front}
          back={cardData[currentCardIndex].back}
          isRevealed={isRevealed}
          onClick={() => {
            if (!isRevealed) {
              setIsRevealed(true);
            } else {
              const nextCardIndex = currentCardIndex + 1;
              setCurrentCardIndex(nextCardIndex);
              setIsRevealed(false);
              if (nextCardIndex < cardData.length) {
                localStorage.setItem("resumeFromCard", nextCardIndex);
              } else {
                localStorage.removeItem("resumeFromCard");
                setReviewState(ReviewState.FINISHED);
              }
            }
          }}
        />
      );
    case ReviewState.FINISHED:
      return "Great job! You finished your current review session.";
  }
};