Hello, my name is Pablo and I work as a backend developer at AnyFactory. For this article though I’ll wear my frontend development hat to present a simple way of dealing with moderately complex client/server interactions on web applications using React
and a robust architecture involving state machine modeling via reducers. Hope you enjoy it!
■ What are state machines?
State machines (known as well as finite-state machines or FSM) are computational constructs that can be used to model linear processes represented by a set of states.
One of the most known usages of state machines are public transportation turnstiles. They have a finite number of states: locked and unlocked. Here is a simple graphic that shows us these states, with their possible inputs and transitions:
The initial state of the turnstile is locked. No matter how many times we may push it, it stays in that locked state. However, if we pass a coin to it, then it transitions to the unlocked state. Another coin at this point would do nothing; it would still be in the unlocked state. A push from the other side would work, and we’d be able to pass. This action also transitions the machine to the initial locked state.
■ State machines implemented with reducers
We can replicate state machines using React
and reducers, which were once part of the Redux
library but are now available as React
hooks. Implementing then state machines with reducers is simple, we can do that following this pattern (in pseudocode):
initial_state = <state>
reducer <- state, action
select state
<state>: select action
<action>: -> next_state
-> state
When the reducer is called with the current state and a given action, it selects a block of code defined for the current state, and then does the same for a block of code defined for the action in the context of the selected state. If the action exists it returns a new state according to what was defined (a valid state transition) and if the action does not exist for the selected state it returns the current state again. For our turnstile example the reducer will be defined this way (again in pseudocode):
initial_state = locked
reducer <- state, action
select state
locked: select action
coin: -> unlocked
unlocked: select action
push: -> locked
-> state
■ Practical example
Now we’re clear about the structure of the reducer, we’re going to design a simple client/server state machine based on the following state diagram:
Let’s start importing React and some hooks we’re gonna need later:
import React, { useReducer, useCallback } from "react";
Now we’re going to implement our state and actions:
const State = {
INITIAL: "INITIAL",
LOADING: "LOADING",
FAILURE: "FAILURE",
SUCCESS: "SUCCESS"
};
const Action = {
REQUEST: "REQUEST",
SUCCEED: "SUCCEED",
FAIL: "FAIL"
};
That’s four states counting the initial state, and three actions to transition from one state to another, the reducer then is implemented like this:
function reducer(state, action) {
switch (state) {
case State.INITIAL:
case State.SUCCESS:
case State.FAILURE:
switch (action.type) {
case Action.REQUEST:
return State.LOADING;
default:
return state;
}
case State.LOADING:
switch (action.type) {
case Action.SUCCEED:
return State.SUCCESS;
case Action.FAIL:
return State.FAILURE;
default:
return state;
}
default:
throw new Error("Invalid state");
}
}
Then we’re gonna add some view components, first we write the view for the INITIAL
state:
function InitialView({ onClick }) {
return (
<React.Fragment>
<p>Welcome!</p>
<button onClick={onClick}>Send first request!</button>
</React.Fragment>
);
}
Nothing special, just a welcome message and a button to send the first request. After sending a request we’re gonna need a view for the LOADING
state, which is just a message informing the user that the request is in flight:
function LoadingView() {
return <p>Sending request...</p>;
}
Then we’re gonna need two final views, one for SUCCESS
and another for FAILURE
states, both allowing us to send another request:
function SuccessView({ onClick }) {
return (
<React.Fragment>
<p>The request succeeded :)</p>
<button onClick={onClick}>Send another!</button>
</React.Fragment>
);
}
function FailureView({ onClick }) {
return (
<React.Fragment>
<p>The request failed :(</p>
<button onClick={onClick}>Try again!</button>
</React.Fragment>
);
}
We’re gonna implement now a controller component that will load the reducer, dispatch our actions and decide which view is rendered depending on the current state. We have to dispatch the REQUEST
action immediately when the views call the event handler defined as onClick
, and to simulate a delayed server response we will be dispatching SUCCEED
or FAIL
actions, by randomly picking one after a timeout of one second:
function RequestController() {
const [state, dispatch] = useReducer(reducer, State.INITIAL);
const dispatchRequest = useCallback(() => {
dispatch({ type: Action.REQUEST });
// Simulates a delayed server response
setTimeout(() => {
dispatch({ type: Math.random() < 0.5 ? Action.SUCCEED : Action.FAIL });
}, 1000);
}, [dispatch]);
switch (state) {
case State.INITIAL:
return <InitialView onClick={dispatchRequest} />;
case State.LOADING:
return <LoadingView />;
case State.FAILURE:
return <FailureView onClick={dispatchRequest} />;
case State.SUCCESS:
return <SuccessView onClick={dispatchRequest} />;
default:
throw new Error("Invalid view");
}
}
Then with a markup like this:
<div id="app"></div>
We can finish our App
component that will render our RequestController
and anything else we want to display:
function App() {
return (
<div>
<RequestController />
</div>
);
}
You can find the complete code running on this code sandbox:
The example application is now finished and we can be reasonably sure that it will behave exactly in the way that we designed it, each view will be presented when is required and the component will never be in an undefined or mixed state.
■ Conclusion
State machines are a simple way of defining valid state transitions that can make your application very robust. Nevertheless, we could go further and add static type checking via Typescript to minimize as well errors that can arise from the implementation of the state machine itself. I’ll leave that as an exercise for the reader and will talk more about static type checking on the next article.
See you then!