afforded.space

State machines in React

May 25, 2020

You’re already using state machines in your code:

Your application is always in just one distinct state. And that state reacts to a set of inputs which may or may not transition it to a different state.

But we oftentimes fail to clearly express these states and transitions. We fail to attach a name to the state a certain event should be handled by. We’re left with a number of blurry and anonymous corners. These corner states are the byproduct of the interplay between seemingly unrelated concerns: “is loading”, “is authorized”, “has error”.

Picture this, a common sight on the web: a filterable, paginated list. Fetch some data, put it in a list, fetch more data, add it to the list, filter the list by some dimension, put the filtered data in the list. It’s easy to start reaching for booleans: “is loading”, “is failed”, “has fetched all pages”.

But relying on booleans makes state logic blurry and more likely to get even blurrier.

Think about the “load more” affordance. We could put it behind two booleans: “not loading” and “has not fetched all pages”. This is an implicit state: we haven’t given it a name.

Embracing explicit, finite-state machines means clearly expressing each distinct state. We tell our application which states it’s allowed to be in. And then we tell each state which events it should care about. We shine a spotlight on the blurry, previously anonymous corner states.

Diagram it

Building a diagram is the ideal first step on the path to making your state machines more explicit. Sticking with our earlier example, let’s diagram a paginated list of New York Times movie reviews, filterable by reviewer.

For “show me the code first” people: code, demo

UML State Diagram

A few things to note:

  • This diagram follows Unified Modeling Language (UML) conventions. UML provides a standardized way to visualize systems like state machines.
  • The rounded diamond shapes are called “choice pseudostates”. Rather than allowing the “has fetched all pages” condition to seep out into our component logic, we encapsulate it in our state machine. Our “load more” button will only be rendered in the “idle” state.
  • States can transition to themselves: if a user filters by a reviewer and then quickly changes her mind by clicking a different reviewer, our application re-enters the “fetching reviews” state. This re-entry transition is the perfect place to abort the first network request. These transitions are often referred to as “self transitions”.

Build it

Now that we have a clear understanding of our states and how they transition, we’re ready to start coding. Our task is fairly straightforward: we need to encapsulate each state such that it can listen for specific transition events (outgoing arrows in our diagram). React’s useReducer hook with some switch statements is all we need:

export default function reducer(state, action) {
  switch (state.status) {
    case STATUS.INITIAL:
      switch (action.type) {
        case "fetchReviewsStart":
          return {
            ...state,
            status: STATUS.FETCHING_REVIEWS,
            controller: action.controller,
          }
        default:
          throw new Error(
            `Unexpected event ${action.type} sent to the '${STATUS.INITIAL}' state.`
          )
      }
    case STATUS.FETCHING_REVIEWS:
      switch (action.type) {
        case "fetchReviewsStart":
          state.controller.abort()
          return {
            ...state,
            status: STATUS.FETCHING_REVIEWS,
            controller: action.controller,
          }
        case "fetchReviewsSuccess":
          // if a new fetch is pending, we don't care about this one
          if (action.controller !== state.controller) {
            return state
          }
          return {
            ...state,
            status: action.payload.has_more
              ? STATUS.IDLE
              : STATUS.HAS_FETCHED_ALL_REVIEWS,
            reviews: [...action.payload.results],
          }
        case "fetchReviewsFailure":
          if (action.controller !== state.controller) {
            return state
          }
          return {
            ...state,
            status: STATUS.FAILED,
            error: action.error,
          }
        default:
          throw new Error(
            `Unexpected event ${action.type} sent to the '${STATUS.FETCHING_REVIEWS}' state.`
          )
      }
    case STATUS.FETCHING_MORE_REVIEWS:
      switch (action.type) {
        case "fetchReviewsStart":
          state.controller.abort()
          return {
            ...state,
            status: STATUS.FETCHING_REVIEWS,
            controller: action.controller,
          }
        case "fetchMoreReviewsStart":
          state.controller.abort()
          return {
            ...state,
            status: STATUS.FETCHING_MORE_REVIEWS,
            controller: action.controller,
          }
        case "fetchReviewsSuccess":
          if (action.controller !== state.controller) {
            return state
          }
          return {
            ...state,
            status: action.payload.has_more
              ? STATUS.IDLE
              : STATUS.HAS_FETCHED_ALL_REVIEWS,
            reviews: [...state.reviews, ...action.payload.results],
          }
        case "fetchReviewsFailure":
          if (action.controller !== state.controller) {
            return state
          }
          return { ...state, status: STATUS.FAILED, error: action.error }
        default:
          throw new Error(
            `Unexpected event ${action.type} sent to the '${STATUS.FETCHING_MORE_REVIEWS}' state.`
          )
      }
    case STATUS.IDLE:
    case STATUS.FAILED:
      switch (action.type) {
        case "fetchReviewsStart":
          return {
            ...state,
            status: STATUS.FETCHING_REVIEWS,
            controller: action.controller,
            reviews: [],
            queryParams: {
              ...state.queryParams,
              ...(!!action.queryParams ? action.queryParams : {}),
            },
          }
        case "fetchMoreReviewsStart":
          return {
            ...state,
            status: STATUS.FETCHING_MORE_REVIEWS,
            controller: action.controller,
          }
        default:
          throw new Error(
            `Unexpected event ${action.type} sent to the '${STATUS.IDLE}' state.`
          )
      }
    case STATUS.HAS_FETCHED_ALL_REVIEWS:
      switch (action.type) {
        case "fetchReviewsStart":
          return {
            ...state,
            status: STATUS.FETCHING_REVIEWS,
            controller: action.controller,
            reviews: [],
            queryParams: {
              ...state.queryParams,
              ...(!!action.queryParams ? action.queryParams : {}),
            },
          }
        default:
          throw new Error(
            `Unexpected event ${action.type} sent to the '${STATUS.HAS_FETCHED_ALL_REVIEWS}' state.`
          )
      }

    default:
      return state
  }
}
  • The cases within each state’s switch correspond to the arrows in our diagram. These are our “transitions”.
  • Both of our fetching states handle transitioning back into a fetching state by aborting the pending request.
  • If a state receives an event it isn’t prepared to handle, we throw an error. This brings those blurry corner states out into the open.

This post was meant to provide an accessible, high level overview of using explicit state machines in React. While useReducer + switch statements are sufficient for this use case, more complex application logic will benefit from something with statechart features.


Get new posts sent to your inbox