Massimiliano Mirra

Notes



















Date:
Tags: react
Status: finished

Expressive components in vanilla React, part 1: TypeStates

Approximate reading time: 5 minutes. Full example code available as codesandbox and repository.

If you’ve written anything but the most basic component, you know that logic in React can turn opaque real fast: you begin with a few booleans — tracking whether a menu is open, whether a request is loading. You throw in some hooks: data fetching, form validation, accessing URL parameters. Soon you’re trying to figure what combination of isThisLoading, isThatValid, isSomethingSuccessful will trigger the screen you want to render. Then a useEffect doesn’t fire when expected. Then another starts firing infinitely.

Something is off. Why is my code is speaking in this strange “UI binary code” instead of terms close to the domain? I thought that was the whole point of using a high-level language?

Keeping this accidental[1] logic-obfuscating complexity to a minimum, is a neverending effort. Two techniques that helped me are type states and IO objects. Neither requires a library and both are easy to pick up. I’ve touched upon IO objects before and will write about the React angle soon (update: part #2 is up); in this note I’ll talk about type states.

To keep things grounded in a real use case, I’ll use this “magic code” login component as an excuse:

Magic Code Login component demo

useState is all you need

No doubt you’ve come across this representation of React’s core idea:

ui = f(data)

Where:

data = props + state

Props are transparent:

export const Note: React.FC<{
  title: string;
  content: string;
}> = () => {
  /* ... */
};

State, however, tends to be all over the place, either in the form of useState, or of hooks that call it internally:

export const MagicCodeLogin: React.FC = () => {
  const [email, setEmail] = useState<null | string>(null);
  const [code, setCode] = useState<null | string>();
  const requestCodeMutation = useMutation(async () => {
    if (code === null) throw new Error("Bug! Email not set");

    const res = await apiClient.requestMagicCode({ email });
  });
  const verifyCodeMutation = useMutation(async () => {
    if (code === null) throw new Error("Bug! Code not set");
    if (email === null) throw new Error("Bug! Email not set");

    const res = await apiClient.verifyMagicCode({
      email,
      code,
    });
  });

  // ...
};

Using type states, you can specify an exhaustive, explicit blueprint of all the states the component can be in, and materialize that blueprint into a single object:

export const MagicCodeLogin: React.FC = () => {
  const [state, transition] = useState<
    | {
        phase: "mounting";
        email: null;
      }
    | {
        phase: "awaiting-email-input";
        email: null;
        error: null | string;
      }
    | {
        phase: "submitting-email";
        email: string;
      }
    | {
        phase: "awaiting-code-input";
        email: string;
        error: null | string;
      }
    | {
        phase: "submitting-code";
        email: string;
        code: string;
      }
    | { phase: "success" }
  >({ phase: "mounting", email: null });

  // ...
};

But isn’t that terribly verbose?

It’s not so much a matter of verbose vs terse, rather of explicit vs implicit. This information has to live somewhere, and I’d rather that be the source code, where the team can review it, and tools can help check and mantain it, than my head. Pixels and bits and cheaper than neurons.

The setter half of useState’s return value is, accordingly, named transition. Most of the time we’ll call it from event handlers:

export const MagicCodeLogin: React.FC = () => {
  const [state, transition] = useState<{
    /* ... */
  }>({
    /* ... */
  });

  const handleEmailSubmit = (email: string) => {
    transition({
      phase: "submitting-email",
      email,
    });

    const res = await apiClient.requestMagicCode({ email });
    if (res.ok) {
      localStorage.setItem("email", email);
      transition({
        phase: "awaiting-code-input",
        email,
        code: null,
      });
    } else {
      transition({
        phase: "awaiting-email-input",
        email: null,
        error: res.error,
      });
    }
  };

  // ...
};

Occasionally we’ll want to transition the state in response to something happening in the “outside world”.

For example, in this component we want to account for the fact that, on mobile, upon receiving the code, the user might close the browser and switch to the mail app to read it instead of peeking in the notifications. When the user comes back and the browser reloads our site, the component needs to remember that the email was already entered, and transition directly to the code input phase.

That can be done in a variety of ways; for simplicity, here we’ll use local storage to save the email. (In a real scenario, it would be better saved to the URL, so that the back button can be a way out for a mistyped address. With local storage, the user would be trapped in the code input phase.) Reading from local storage is where useEffect comes in:

export const MagicCodeLogin: React.FC = () => {
  const [state, transition] = useState<{
    /* ... */
  }>({
    /* ... */
  });

  useEffect(() => {
    switch (state.phase) {
      case "mounting": {
        const storedEmail = localStorage.get("email");
        if (storedEmail === null) {
          transition({
            phase: "awaiting-email-input",
            email: null,
            error: null,
          });
        } else {
          transition({
            phase: "awaiting-code-input",
            email: storedEmail,
            error: null,
          });
        }
        break;
      }
      case "success": {
        // redirect to home page
        break;
      }
    }
  }, [state.phase]);

  // ...
};

Rendering

Rendering is trivial: just switch on state.phase. Data in each branch will automatically be narrowed to the phase-relevant subset thanks to TypeScript:

export const MagicCodeLogin: React.FC = () => {
  const [state, transition] = useState<{
    /* ... */
  }>(/* ... */);

  useEffect(() => {
    /* ... */
  }, [state.phase]);

  const handleEmailSubmit = (email: string) => {
    /* ... */
  };

  return (
    <div>
      {state.phase === "awaiting-email-input" ||
      state.phase === "submitting-email" ? (
        <EmailForm
          onSubmit={handleEmailSubmit}
          isSubmitting={state.phase === "submitting-email"}
          error={
            state.phase === "submitting-email"
              ? null
              : state.error
          }
        />
      ) : state.phase === "awaiting-code-input" ? (
        <CodeForm
          email={state.email}
          error={state.error}
          onSubmit={handleCodeSubmit}
          onInput={handleCodeInput}
        />
      ) : state.phase === "submitting-code" ? (
        <Text>Verifying...</Text>
      ) : state.phase === "success" ? (
        <Text>Success! Redirecting...</Text>
      ) : null}
    </div>
  );
};

Avoiding useEffect hell

One way complexity can sneak in again is through useEffect’s dependencies. As soon as you need to read anything other than state.phase inside useEffect, you must add it to its dependency list, and now instead of the simple mental model:

phase change → useEffect call

You have (for example):

phase change or email change → useEffect call → “what happens if email changes while phase doesn’t and and and…”

For that reason, I suggest you stick to reading only state.phase in useEffect. For synchronous state changes that require reading the existing state, you can still use the function version of transition without affecting the dependency list:

transition((oldState) => ({
  phase: "error",
  email: oldState.email,
  error: "server error",
}));

Isn’t this just a state machine?

Yes! (The expression “type state” itself comes from XState’s documentation.)

And so are components that rely on “boolean soups”. The choice isn’t really between building a state machine or not, it’s rather between building it implicitly or explicitly.

Where would I want to use this?

My experience has been:

  1. Presentational components? Not needed.
  2. Components whose behavior is described by just one or two if this, then that? I can get away with booleans.
  3. Components that make me think (even remotely) of “multi-step”, and anything else between 2 and 4: I go for this pattern.
  4. Full-blown mini-apps: I reach for full-blown logic abstractions.

One thing to keep in mind is that the space between 2 and 4 is vast, and components jump from 2 into 3 frequently and unexpectedly. If anything, I’ve stopped resisting that and started going for type states more quickly.

Summary

Type states help clarify the component’s behavior so that it’s easier to reason about it, communicate it to fellow developers, and even get tooling to help with maintenance and verification. It requires no libraries, only vanilla React and TypeScript.

Credits

Many thanks to Pete for sanity-checking the content as well as for precious readability hints, and to Max for reviewing the code and highlighting the risk of overly complex useEffect.


  1. In The Mythical Man-Month, Fred Brooks talks about two types of complexity: essential and accidental. Essential complexity comes from the problem itself and is unavoidable. Accidental complexity, on the other hand, is introduced by tools, languages, architectures, and can (and should) be minimized. ↩︎

I write in the hope of making other people’s developer journey smoother and more rewarding. If you noticed a mistake or have suggestions on how to improve this article, please let me know. If you think this article can be useful to others, please consider sharing it:

Stay in touch

© 2020-2024 Massimiliano Mirra