DEV Community

Andrew Petersen
Andrew Petersen

Posted on • Edited on

React State Machine Hook

This custom hook is meant to live somewhere in between the built-in useReducer and pulling in a 3rd party library like xstate.

let { state, status } = useStateMachine(
    stateChart, 
    initialState, 
    updaters,
    transitionEffects?
);
Enter fullscreen mode Exit fullscreen mode

It's not quite useful/big enough to warrant an NPM package, so I created a code snippet and will document it here for the next time I reach for it.

1. Document the State and available Statuses

The State Machine will track 2 things,

  1. status - the State Machine's state, called status to avoid confusing with React state.
  2. state - The stateful data that should be tracked in addition to status. This is just like the state for useReducer.
export interface AuthState {
  error: string;
  currentUser: { 
    uid: string; 
    name: string; 
    email: string 
  };
}

const initialState: AuthState = {
  currentUser: null,
  error: ""
};

export type AuthStatus =
  | "UNKNOWN"
  | "ANONYMOUS"
  | "AUTHENTICATING"
  | "AUTHENTICATED"
  | "ERRORED";
Enter fullscreen mode Exit fullscreen mode

2. Create the State Chart

For each status, what actions can be performed? If that action runs, to which status should it transition?

The names of the actions should match the names of the updaters in the next step.

const stateChart: StateChart<AuthStatus, typeof updaters> = {
  initial: "UNKNOWN",
  states: {
    UNKNOWN: {
      setCachedUser: "AUTHENTICATED",
      logout: "ANONYMOUS",
      handleError: "ERRORED"
    },
    ANONYMOUS: {
      loginStart: "AUTHENTICATING"
    },
    AUTHENTICATING: {
      loginSuccess: "AUTHENTICATED",
      handleError: "ERRORED"
    },
    AUTHENTICATED: {
      logout: "ANONYMOUS"
    },
    ERRORED: {
      loginStart: "AUTHENTICATING"
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

3. Implement the State Updaters

A state updater is a function that takes in the current state (a React state) and the triggered action, then returns the updated state. Just like a reducer.

(state, action) => updatedState

  • Under the covers, useStateMachine will bind the updaters to dispatch and return actions you can call like actions.handleError({ error }).
  • Some actions are triggered just to cause a State Machine status transition (like loginStart). In this case, the updater should return the state right back.

The names of the updaters should match the names of the actions in the State Chart.

const updaters = {
  loginSuccess: (state, { user }) => {
    cacheCurrentUser(user);
    return {
      error: "",
      currentUser: user
    };
  },
  setCachedUser: (state, { user }) => {
    return {
      error: "",
      currentUser: user
    };
  },
  logout: (state) => {
    cacheCurrentUser(null);
    return {
      error: "",
      currentUser: null
    };
  },
  handleError: (state, { error }) => {
    return {
      ...state,
      error: error.message
    };
  },
  loginStart: (state, { username, password }) => state
};
Enter fullscreen mode Exit fullscreen mode

4. Use and Define Transition Effects

The last step is to use the hook.

You can also define effect functions to be run when the state machine transitions into a specified status. This is useful for doing async work.

The enter transition effect function is given the action that caused the transition as well as all the available actions.

In this example, when the user calls, loginStart, the status will transition to AUTHENTICATING, which will fire the transition effect to call api.login. Based on the result of login(), either the success or error action is triggered.

function useAuth() {
  let stateMachine = useStateMachine(stateChart, initialState, updaters, {
    AUTHENTICATING: {
      enter: async ({ action, actions }) => {
        try {
          let user = await api.login({
            username: action.username,
            password: action.password
          });
          actions.loginSuccess({ user });
        } catch (error) {
          actions.handleError({ error });
        }
      }
    },
    UNKNOWN: {
      enter: () => {
        let cachedUser = getCurrentUserFromCache();
        if (cachedUser && cachedUser.token) {
          stateMachine.actions.setCachedUser({ user: cachedUser });
        } else {
          stateMachine.actions.logout();
        }
      }
    }
  });

  // { actions, state, status }
  return stateMachine;
}
Enter fullscreen mode Exit fullscreen mode

Here is the full login form example implemented in Code Sandbox.

Top comments (2)

Collapse
 
alfredosalzillo profile image
Alfredo Salzillo

Reducers are too simple for you?

Collapse
 
droopytersen profile image
Andrew Petersen • Edited

Not at all, the useStateMachine implementation is actually built on top of useReducer.

I often just use useState or useReducer straight up, but occasionally I feel like a screen warrants a state machine because a state machine naturally solves for a lot of edge cases that require a lot of code to handle in a traditional useReducer.

For example, I shouldn't be able to submit the form if it is invalid. In a reducer I'd have to catch the submit action in a case statement, then check to make sure the status was not INVALID or DIRTY. A state machine automatically guards against this because only actions that are valid on the current status will be allowed to be triggered.