Async Superpowers — Some Guidance

Floyd May
4 min readDec 29, 2021

--

This is the final article in a series, Async Superpowers: State Machines, geared for helping you understand how to easily build and test¹ UIs that have asynchronous things going on. The first article in the series is here.

Here in this last article, I’d like to spend some time giving you some general guidance as you learn how to use state machines to tame UIs. All of what you’re about to read comes from real-world experience. In other words, this is how I’ve messed it up in the past.

Ignoring Irrelevant Actions

One of the beauties of using a state machine to manage a UI is that the machine ignores irrelevant actions. For instance, let’s consider a classic problem. An over-eager user² double-clicks a button that, say, makes a purchase, and causes the purchase to be duplicated³. In this kind of situation, any subsequent clicks after the first one should be ignored. When we use a state machine to manage these interactions, the button click should dispatch an action that causes a state transition:

A part of a potential state machine for our purchase scenario.

Given the state machine above, the button click would dispatch the Confirm purchase action, and our state machine would transition to the Purchase pending state. Even if rapid-fire clicking manages to dispatch more than one Confirm purchase action, our state machine ignores all but the first of those actions.

Initiate Asynchronous Operations from States

In our purchase example above, a key element of preventing duplicate purchase attempts is that we initiate the HTTP call only when we arrive at the Purchase pending state. It is the state that indicates when to invoke the HTTP call and not the Confirm purchase action. Always initiate asynchronous operations based on the state; never based on an action. This keeps the state machine in control of which actions are (and are not) relevant. If you review the “async reactor” we built in the previous article, it initiates async operations based on the state, and never the action.

Reference Equality on Ignored Actions

A key element that prevents our async reactor from duplicating asynchronous operations is the useEffect hook in React. It only invokes the callback when the given state changes:

useEffect(() => {
reactToState(state, dispatch);
}, [state]);

Regardless of how many times this component is re-rendered, the call to reactToState(...) will only be invoked if state changes vis a vis reference equality; that is, if the new value of state is not the exact same object it was previously. If, say, when processing a duplicate Confirm purchase action, our reducer function returned { ...state } (that is, generating a new, equivalent object with the same data) rather than state, this useEffect(...) callback would be invoked again, possibly attempting a duplicate purchase.

Whether or not you’re using React, your async reactor should only initiate communications when the state changes. The simplest solution to this is to ensure that your reducer returns the same state when it ignores an action and never returns a clone or copy of the state. This ensures that a new state object is produced only when a state transition has occurred. If you aren’t careful about this, some potential problems could include:

  • Repeatedly sending duplicate HTTP requests
  • Infinite loops
  • Stack overflows

Always make sure you return the exact same state when an action is ignored.

There Is No Such Thing As In-between States

You may find yourself tempted to have things happen “between” states; that is, your UI might have an animation or something like it that’s timing-related, but quick, and you’d rather not model that “in between” transitionary part as yet another state in your state machine. Resist that temptation.

Watch Out for Complexity

Be aware of how complex your state machines are. A machine with four to six states is pretty comfortable to work with. Nine or ten states will often start becoming unwieldy, and a dozen states is almost certain to be too complex.

When you discover that a state machine has become too complex, revisit your UI to see if you can decompose it into separate components that use simpler state machines.

Conclusion: Guiding Principles

Now that you’re ready to try out your new async superpowers and tame some UIs, let me give you a quick list of what we’ve covered:

  • Model your UI as a set of states
    This lets you can easily build and test the visual representation of each state in isolation, especially those tricky “loading” states. Be sure to test that the UI dispatches the correct actions given specific user input.
  • Design a state machine
    Put the state machine in control of which actions allow transitions from one state to another. Make sure that your machine ignores invalid or irrelevant actions.
  • React to state changes
    Initiate asynchronous communications in a “reactor” that reacts to changes to the state (and not to dispatched actions).
  • Write good tests
    Each component should be thoroughly testable in isolation, and one or two higher-level integration tests can help ensure that everything is working together as intended

Footnotes

  1. Yes, those tests. You know. Automated ones.
  2. Or a clever QA engineer.
  3. A well-designed backend wouldn’t allow this, and would likely produce errors on subsequent attempts to confirm a purchase.

--

--