Async Superpowers, Part 2

Floyd May
7 min readDec 29, 2021

--

This is the second in a four-part series that will help you easily write and test UIs that do asynchronous stuff.

(Heads up: I’ll be doing pseudocode examples in Typescript and React. This isn’t a necessity for state machine driven UIs; however, having some familiarity with both would be helpful in reading and understanding this series.)

So, in the last article, I introduced you to state machines. Now, let’s see if we can take that knowledge and apply it to a real-world UI. Let’s say we’re building a UI that searches for things, and displays the things that match the search criteria. Here’s a sketch of what that might look like:

Our UI

Now, let’s also say that we have to do some loading before we can display the above screen. We’ve got to load the available options for those dropdowns from the backend as well as the initial results, let’s say. Let’s also say that we have an “infinite scroll” type scenario, where we load another page of data when the user scrolls down far enough. So, we have four states of our UI. Our initial “loading” state:

Let’s call this our “Loading” state

Next, our “loaded” state that shows results:

The “Loaded” state of the UI

Third, we have a state that we show when we’ve changed our search criteria and are reloading results:

The “Reloading” state

And finally, our “loading more” state that we enter when the “infinite scroll” kicks in and we load another page of results:

The “Loading more” state

As you may have noticed, I’ve modeled every possible state that this UI could be in. As you might suspect, these will all correspond to states in a state machine. What you may have also noticed is that I’ve modeled states not just for when the UI is loaded, but for the various situations where the UI is waiting for data to load.

State Data

In order for this UI to display itself properly, it needs data. For all but the “Loading” state, the UI needs the list of things and the dropdown options. If we were to define that in code, it might look like this:

type ThingSearchData = {
things: Thing[];
options1: Options[];
options2: Options[];
options3: Options[];
searchText: string;
selected1: string;
selected2: string;
selected3: string;
}

However, the UI displays differently depending on which state it’s in. In Typescript, I tend to represent the states and their data as a “tagged” union type¹, like this:

type Tagged<T extends string> = { tag: T };type Loading = Tagged<"Loading">;
type Loaded = Tagged<"Loaded"> & ThingSearchData;
type Reloading = Tagged<"Reloading"> & ThingSearchData;
type LoadingMore = Tagged<"LoadingMore"> & ThingSearchData;
type State = Loading | Loaded | Reloading | LoadingMore;

This lets us write code that accepts a State and then inspect it to see which state it is using typescript’s “narrowing”:

if(state.tag === "Loading") {
renderLoading(state);
} else if(state.tag === "Loaded") {
renderLoaded(state);
} // and so on...

(Quick note: you could use a class hierarchy in a language like C# or Java to accomplish the same goal of representing your states’ data. This isn’t a Typescript only concept!)

With our State type, we can now write some tests for our UI to make sure it displays itself properly given the right state. For example:

describe("Loaded state", () => {
const state = {
tag: "Loaded",
things: [
{ name: "A thing" },
{ name: "Another thing" },
{ name: "yet another thing" },
],
options1: [{ /* omitted for brevity */ }],
/* and so on... */
};
beforeEach(() => {
render(<ThingSearch state={state} />);
});
it("should not show spinner", () => {
expect(document.querySelectorAll(".spinner")).toHaveLength(0);
});
state.things.forEach(thing => {
expect(screen.getByText(thing.name)).toBeInTheDocument();
});
});

So, we’ve solved one part of the problem: given a state, what should get displayed. We’ve also got a testing strategy for that. However, we still a few more details to sort through.

Input and Actions

Now, let’s examine, in our UI, what user input we need to handle. There are two distinct user interactions that we need to be concerned with:

  • Search criteria changed — this occurs when the user changes the search text or any of the dropdowns. This will (eventually) lead to loading a new list of things.
  • Scrolled to bottom — this occurs when the user scrolls the page to the bottom, triggering the “infinite scroll” behavior that loads more data as the user scrolls.

For the remainder of these articles, we’ll call these state machine inputs actions. We’ll also define some more actions for our UI’s state machine, but for now, let’s focus on these two. We need to represent these in code, and we’ll use a similar definition to our State type earlier:

type SearchCriteriaChanged = Tagged<"SearchCriteriaChanged"> & {
searchText: string;
option1: string;
option2: string;
option3: string;
}
type ScrolledToBottom = Tagged<"ScrolledToBottom">
type Action = SearchCriteriaChanged | ScrolledToBottom;

Given these definitions, we can now test that our UI properly produces the actions it should as the user types, clicks, and scrolls. For instance:

it("Dispatches ScrolledToBottom action", () => {
const actions: Action[] = [];
render(<ThingSearch
state={loadedState}
dispatch={a => actions.push(a)}
/>);
const container = document.querySelector(".thing-list");
fireEvent.scroll(container, { target: { scrollY: 1000 } });
expect(actions).toEqual([{
tag: "ScrolledToBottom",
}]);
});

Writing the State Machine

So far, we have a UI that displays itself properly based on its state, and the UI produces actions based on user input. However, we still don’t yet have code that allows us to move from state to state based on those actions. So, let’s discuss how to represent a state machine in code. Our UI’s state diagram should look something like this:

In the previous article, I said that transitions between states are labeled with the input that’s being processed by that transition. In the parsing examples, for instance, that input was letters. In the rollercoaster example, the input fell into two categories: button presses by the operator (open gates, close gates, launch, release restraints), and sensor input (coaster arrived). Similarly, our UI also has two categories of input, or, more precisely, two categories of actions. Our first category is user input. scrolled to bottom and search criteria changed are the direct user interactions that our state machine accepts. We also have another kind of action: asynchronous results; that is, stuff that is likely obtained via a fetch(...) call or similar. I’ve represented the transitions for those actions using dashed lines.

A key element in the design of this state machine is that we have states that represent when asynchronous things are pending. This is the core idea that lets us turn the complicated and difficult-to-test messiness of asynchronous operations into a straightforward, easy-to-test state machine. We can represent the data that gets returned from our asynchronous operations as our state machine’s actions, and prove that the machine transitions correctly given an action. For instance, our AdditionalDataLoaded action would probably be defined like this:

export type AdditionalDataLoaded = Tagged<"AddlDataLoaded"> & {
things: Thing[];
}

In the previous article I said that a state machine decides, given a current state and an action, what the next state should be. In code, this is a function whose signature looks like this:

(state: State, action: Action) => State

So, given one of our states and some action, we should be able to compute another state. This kind of function is called a reducer, and reducers typically take the form of nested switch statements or if/else chains:

function reduce(state: State, action: Action): State {
if(state.tag === "Loading") {
return reduceLoading(state, action);
} else if(state.tag === "Loaded") {
return reduceLoaded(state, action);
} // and so on
return state;
}
function reduceLoading(state: Loading, action: Action): State {
if(action.tag === "DataLoaded") {
return {
things: action.things,
options1: action.options1,
options2: action.options2,
options3: action.options3,
tag: "Loaded",
};
}
return state;
}
// additional reduceXXX functions omitted for brevity

Given a state machine expressed as States, Actions, and a reducer function, putting the state machine under test is very straightforward. For example, here are some tests for the Loading state:

describe("Loading state", () => {
const state = { tag: "Loading" };
it("ignores irrelevant action", () => {
const result = reduce(state, { tag: "ScrolledToBottom" });
expect(result).toEqual(state);
});
it("transitions to Loaded on DataLoaded action", () => {
const action = {
things: [{ name: "Thing 1" }],
options1: [{ label: "Opt1 label", value: "1"],
options2: [],
options3: [],
};
const result = reduce(state, action);
expect(result).toEqual({
...action,
tag: "Loaded",
});
});
});

Next Up: Putting It All Together

We’ve solved quite a few problems up to this point, including writing tests for each:

  1. The UI displays properly given some State
  2. The UI produces the right Action from user input
  3. The state machine’s reducer function generates the correct next State given a current State and an Action

However, we still have two important problems left to solve:

  1. Connecting the UI to a working state machine
  2. Making the asynchronous calls at the right time, dispatching Actions when they complete

In the next article, we’ll see how to connect all of this together so that our UI functions properly end-to-end.

Footnotes

[1]: The Tagged<T> type and its “tag” property is inspired by my distaste for the fact that Typescript doesn’t support tagged unions

--

--