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?
);
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,
-
status
- the State Machine's state, calledstatus
to avoid confusing with Reactstate
. -
state
- The stateful data that should be tracked in addition tostatus
. This is just like the state foruseReducer
.
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.
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 theupdaters
todispatch
and returnactions
you can call likeactions.handleError({ error })
. - Some actions are triggered just to cause a State Machine
status
transition (likeloginStart
). In this case, the updater should return thestate
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
};
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;
}
Here is the full login form example implemented in Code Sandbox.
Top comments (2)
Reducers are too simple for you?
Not at all, the
useStateMachine
implementation is actually built on top ofuseReducer
.I often just use
useState
oruseReducer
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 thestatus
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.