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:
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 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>
);
};
useEffect
hellOne 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",
}));
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.
My experience has been:
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.
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.
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
.
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. ↩︎