dhilst

How I write React components as state machines

Sometime ago I started working with React again and after some few weeks I already had one of that components with dozens of useState calls. At that point I learned useReducer hook (I already used Redux before but never this hook). In this post I show how I write components with useReducer as state machines and how this make it more predictable and easier to manage the state updates.

useReducer is a React hook to let you use a reducer, i.e. a function that receives a state, and an action and returns a new state.

The difference from useReducer to Redux is that Redux is a full library to manage the global state of your app, while useReducer is a hook, and will only manage the state of a single component. Even in this case it is very useful!

The idea is to replace multiple useState by a single useReducer. This helps to organize the state updates. Do you ever faced a problem where you need to update two states at same time, ex setShowError(true); setError("some error"), and then you need to consume showError and error at the same time? ex; if (showError && error !== null) ....

The problem here is that error and showError depends one on each other. We want an invariant like: when error is true, showError is not null but React cannot guarantee that. The solution is to merge error and showError states in a single state object and do a single update. Then we’re sure that the dependence between both values hold because they are updated together.

In this way your component is always in a valid state, there are no intermediary states were one value is updated but the other is not.

I’m going to show a generic example, I’m using NextJS 13 BTW

Here is some code, I’m explaining it in the comments!!

"use client";
import { default as axios } from 'axios';
import React, { useReducer } from 'react';

export default function MyComponent() {
  // I define a state for my component. There is only ONE
  // state and ONE reducer function which will update it.
  type State = {
    // I like to add a enum like variable to define the
    // state of my component. All other values depending on
    // the state of the component read it from here
    state: "Init" | "Request" | "Response" | "Error",
    // The other fields depend on the `state` field. For
    // example, `response?` is only present when `state == "Response"`.
    response?: string,
    error?: string,
  };

  // I also define a set of actions that may be triggered in the component
  type Action =
    | { type: "Request" }
    | { type: "Response"; payload: Response }
    | { type: "Close" }
    | { type: "Error", payload: string }
    ;

  // @ts-ignore
  const [state, dispatch]: [State, React.Dispatch<Action>] = useReducer((state: State, action: Action): State => {
    // This is the reducer, it returns a new state based on the `action.type`
    switch (action.type) {
      case "Request":
        return { state: "Request" }
      case "Response":
        // Both fields are updated at same time
        return { state: "Response", response: action.payload }
      case "Error":
        return { state: "Error", error: action.payload };
      case "Close":
        return { state: "Init" }
      default:
        return state;
    };
  }, { state: "Init" });

  const onClick = async (e: any) => {
    e.preventDefault();

    try {
      switch (state.state) {
        case "Init":
          // The reducer is completely synchronous. We
          // dispatch an action to notify that a request
          // will be fired, this can be used to trigger
          // loading state for example
          dispatch({ type: "Request" });
          // Then we wait for the request
          const response = await axios.get("example.com");
          // Finally we dispatch the response results, again,
          // synchronous
          dispatch({ type: "Response", payload: response.data });
          return;
        case "Request":
          return;
        case "Response":
        case "Error":
          dispatch({ type: "Close" });
          return;
        default:
          console.error(`Invalid state ${state.state}`);
          return;
      }
    } catch (e: any) {
      dispatch({ type: "Error", payload: e?.response?.data || e })
      console.error(e);
    }
  };

  switch (state.state) {
    case "Init":
      return <button onClick={onClick}/>Initial state</button>;
    case "Request":
      return <button>Loading ...</button>;
    case "Response":
      return (<div>
        <button onClick={onClick}/>Go back to inital state</button>
        <p>Response: {state.response}</p>
      </div>);
    case "Error":
      return (<div>
        <button onClick={onClick}/>Go back to inital state</button>
        <p>Error: {state.error}</p>
      </div>);
    default:
      return <button onClick={onClick}/>Initial state</button>;
  };
}

Because all state is in a single object, all updates are atomic, and updates can be arbitrarily complex. The state.state make clear the possible states and the actions state the possible state transitions.

If we need a new state variable we add it a field the state type, then we add an action that will trigger the update of such field, we update the reducer and the remaining of the code.

This creates a lot of boilerplate, yes, but, at last for me, it make clear what the state transitions are, what are the possible state and how to update the code in order to add new states without breaking everyting else.

The above component works like a state machine with these transitions:

Init     -Request->   Request

Request  -Response->  Response
Request  -Error->     Error

Response -Close->     Init
Error    -Close->     Init

The word inside the arrows are the actions. For example, in the initial state Init the only valid transition is by the Request action (which is triggered when the user clicks the button). The component goes then to the Request state. In this state it can go to two possible states Response or Error. Reponse will show the reponse and Error the error. From Error or Respose the only transition is to Init triggered by Close action (fired by the user again).

So is very easy to have a mental picture of how your component should work and this helps a lot into adding new features and debugging.

That’s it!