This is the third in an four-part series that will help you easily write and test UIs that do asynchronous stuff.
Recap
In the previous two articles, we learned about how to model real-world computing problems with a state machine. We played with parsing strings, controlling a rollercoaster, and modeling a search UI. We also showed how we use a state and its associated data to prove (via automated tests) that the UI renders properly when it’s in a given state. We also proved that the UI dispatches actions in response to user input.
Connecting It All Together
We have two remaining problems to solve. One, we need to “drive” our state machine so that actions cause the UI’s state to change; and two, we need to initiate those asynchronous communications at the right times and dispatch actions when they complete.
(Quick note: I’m using React for my code samples here, but don’t let that deter you if you’re using some other tech! This technique is applicable in any programming language and/or UI framework.)
For the first problem, we’ll use React’s useReducer
hook. This is, unsurprisingly, built exactly for usages like ours:
const ThingSearchUI = () => {
const [state, dispatch] = useReducer(reducer, { tag: "Loading" }); return <ThingSearch state={state} dispatch={dispatch} />;
};
Let’s take a closer look. The useReducer
function accepts a reducer function (the reducer we built in the previous article), and an initial state. In our case, the initial state for our state machine is Loading
. The return values are the current state
and a dispatch
function. Notice how we’re passing this state
and dispatch
directly to ThingSearch
, which we also learned how to test in the previous article. Now, our UI renders itself properly in each state and actions generated from user input are now “connected” to our reducer function, causing the state machine to transition.
If you’re using some other framework, you could replicate useReducer
using something like this:
// assuming `this.state` and `this.dispatch` are available
// for use in your child components (or similar)
class ThingSearch extends Component {
onInit() {
this.state = { tag: "Loading" };
} dispatch(action: Action) {
this.state = reducer(this.state, action);
}
}
Next, we need to initiate those asynchronous communications at the right times. For the three “asynchronous result” transitions in our state machine, they begin at the Loading
, Reloading
, and Loading More
states. So, when our machine arrives at each state, we should initiate those asynchronous calls (like, say, using the fetch
API to get some data over HTTP). So, we could write a function that, given a state and a dispatch function, makes the correct fetch
call and dispatches the correct action:
async function reactToState(state, dispatch) {
if(state.tag === "Loading" || state.tag === "Reloading") {
const response = fetch({
method: "POST",
path: "/api/thingsearch",
content: { state.searchText, /* ... */ }
});
const data = await response.json(); dispatch({
tag: "Data Loaded",
things: data.things,
/* ... */
});
}
/* and so on */
}
Then, we can fashion a React component that uses this function when the state changes:
const ThingSearchReactor = ({ state, dispatch }) => {
useEffect(() => {
reactToState(state, dispatch);
}, [state]); return <React.Fragment />
}
I call this kind of component that only listens for state changes and initiates asynchronous operations an “async reactor”. Tests for this component would likely check for initiating the right HTTP communications given some state, and dispatching the correct action when the HTTP call completes. Then, finally, we can compose the entire thing together like this:
const ThingSearchUI = () => {
const [state, dispatch] = useReducer(reducer, { tag: "Loading" });return (<>
<ThingSearch state={state} dispatch={dispatch} />
<ThingSearchReactor state={state} dispatch={dispatch} />
</>);
};
Zooming Out
At this point, let’s take a step back and revisit what problem we were solving and see how all of these disjointed code snippets fit together. We have a “Thing Search” UI that has a search box, some dropdowns, and a list of “things”:
This UI has a number of states where we’re waiting for asynchronous operations to complete. Our solution for this has three major components:
- The state machine
This is implemented as a reducer function that accepts a currentState
and anAction
and returns a newState
. We modeled every behavior of our UI, including user interactions and asynchronous results, into this state machine. - The display component
This is the portion that displays UI based on a givenState
and dispatchesAction
s when the user interacts with the UI. - The async reactor
This component listens for state changes and initiates the asynchronous communications for our various “loading” states.
Each of these components can be thoroughly and easily unit tested in isolation, and composing them together is quite simple. A larger-scale “integration” test or two¹ could ensure that we’ve composed these pieces correctly.
A working example app based on create-react-app is posted here (thanks GitHub!). In the final article in this series, we’ll discuss some design guidance for modeling your own UIs using state machines and explore some common pitfalls. Stick around!
Footnotes
- No, we’re not going to have an argument about what is and isn’t an integration test