React State Machine Hook
Tags: react, webdev, typescript, javascript
Published: 2020-11-26 07:30:22

This custom hook is meant to live somewhere in between the built-in `useReducer` and pulling in a 3rd party library like [xstate](https://xstate.js.org/).

```js
let { state, status } = useStateMachine(
    stateChart, 
    initialState, 
    updaters,
    transitionEffects?
);
```

It's not quite useful/big enough to warrant an NPM package, so I created a [code snippet](https://codesnippets.dev/droopytersen/hooks-core/useStateMachine.ts) 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`.

```tsx
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";
```

# 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.

```tsx
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"
    }
  }
};
```

# 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.

```tsx
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
};
```

# 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.

```tsx
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;
}
```

Here is the full login form example implemented in Code Sandbox.
{% codesandbox ymv9x %}