Someone in finance changed the IBAN that payouts go to, pressed “Verify account” and watched the button spin under a red message saying the account couldn’t be verified. Both were on screen at once. They clicked again, to be sure, and the bank received two checks for the same account.
The component tracked the check with three flags: checking, an error and verified. Retrying set checking to true and left the last error where it was, and nothing stopped a second click while the first request was out. Three booleans can describe eight screens. The process has four.
One status replaces the flags: a single value that names the state the process is in, idle, checking, verified or failed, carrying the data that state needs. A spinner under an error can’t render, because no value of the status means both.
Three flags, eight screens
Here is the check as it shipped. Each line is reasonable on its own:
// Avoid: three flags for one process
const [isChecking, setIsChecking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isVerified, setIsVerified] = useState(false);
async function handleCheck() {
setIsChecking(true); // the last error stays on screen
try {
await checkAccount(iban);
setIsVerified(true);
} catch {
setError("We couldn’t verify this account.");
} finally {
setIsChecking(false);
}
}Nothing clears error when a new check starts, so a retry shows the spinner and the old failure together. Nothing ignores a click while checking, so a double click sends two requests. Each fix is one more line in the handler, and each new state, such as a pending manual review, doubles the combinations again.
| Flags set | On screen |
|---|---|
| checking, error | A spinner under a failure |
| checking, verified | A spinner beside a confirmation |
| error, verified | A failure beside a confirmation |
| all three | All of the above |
A dial, not a row of switches
Switches can be set in any combination. A dial points at one position at a time. When two flags should never be true together, they were one value all along, and the dial is the right shape for it. React’s guide to choosing the state structure makes the same call for a form’s sending and sent flags.
The positions are the states the process can be in. The moves between them are a short, written list, and anything not on the list doesn’t happen.
Writing the check as one value
The check becomes a type, a reducer that moves it, and a component that renders from it. The type and the reducer live in verification.ts.
The states, and what each carries
Each state is a member of one union, tagged by status. Look at where the data sits:
export type Verification =
| { status: "idle"; attempt: number }
| { status: "checking"; attempt: number }
| { status: "verified"; attempt: number; last4: string }
| { status: "failed"; attempt: number; reason: string };
export const INITIAL: Verification = { status: "idle", attempt: 0 };reason exists only on a failure and last4 only on a success, so the type won’t let the page read an error message while it is checking. attempt counts the checks started, which the next step relies on.
The moves, in one place
A reducer, read with useReducer, decides which moves are allowed. Two rules matter:
export type VerificationEvent =
| { type: "check"; attempt: number }
| { type: "succeeded"; attempt: number; last4: string }
| { type: "failed"; attempt: number; reason: string }
| { type: "edited" };
export function verify(
state: Verification,
event: VerificationEvent,
): Verification {
switch (event.type) {
case "check":
// A second click while checking changes nothing.
return state.status === "checking"
? state
: { status: "checking", attempt: event.attempt };
case "succeeded":
case "failed": {
// An answer to an earlier attempt, or after an edit, is
// dropped.
if (
state.status !== "checking" ||
state.attempt !== event.attempt
)
return state;
return event.type === "succeeded"
? {
status: "verified",
attempt: event.attempt,
last4: event.last4,
}
: {
status: "failed",
attempt: event.attempt,
reason: event.reason,
};
}
case "edited":
// A different IBAN: whatever was known about the old one no
// longer applies.
return state.status === "idle"
? state
: { status: "idle", attempt: state.attempt };
}
}A click while checking returns the same state, so nothing re-renders; the guard in the handler is what keeps a second request from going out. An answer is accepted only while checking and only for the current attempt, so a bank reply that arrives after the IBAN was edited can’t mark the new IBAN as verified.
Sending the check
The handler numbers the attempt and reports the answer under that number:
async function handleCheck() {
if (state.status === "checking") return;
const attempt = state.attempt + 1;
dispatch({ type: "check", attempt });
try {
const { last4 } = await checkAccount(iban);
dispatch({ type: "succeeded", attempt, last4 });
} catch {
const reason =
"The bank didn’t recognise this IBAN. Check and try again.";
dispatch({ type: "failed", attempt, reason });
}
}Rendering from the status
The button’s label is a record keyed by status, so TypeScript requires a label for every state, and the status line is a switch over the same union:
"use client";
// Payout settings: verify the bank account payouts go to. The check
// is one value, and only the reducer in verification.ts moves it.
import { useId, useReducer, useState } from "react";
import { INITIAL, verify } from "./verification";
import type { Verification } from "./verification";
export type CheckAccount = (
iban: string,
) => Promise<{ last4: string }>;
const BUTTON: Record<Verification["status"], string> = {
idle: "Verify account",
checking: "Checking…",
verified: "Verify again",
failed: "Try again",
};
function statusText(state: Verification): string {
switch (state.status) {
case "idle":
case "failed":
return "";
case "checking":
return "Checking the account with your bank…";
case "verified":
return `Verified: payouts go to account ending ${state.last4}.`;
}
}
export function BankAccount({
checkAccount,
}: {
checkAccount: CheckAccount;
}) {
const id = useId();
const [iban, setIban] = useState("");
const [state, dispatch] = useReducer(verify, INITIAL);
async function handleCheck() {
if (state.status === "checking") return;
const attempt = state.attempt + 1;
dispatch({ type: "check", attempt });
try {
const { last4 } = await checkAccount(iban);
dispatch({ type: "succeeded", attempt, last4 });
} catch {
const reason =
"The bank didn’t recognise this IBAN. Check and try again.";
dispatch({ type: "failed", attempt, reason });
}
}
return (
<section aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>Payout account</h2>
<label htmlFor={`${id}-iban`}>IBAN</label>
<input
id={`${id}-iban`}
value={iban}
autoComplete="off"
spellCheck={false}
onChange={(event) => {
setIban(event.target.value);
dispatch({ type: "edited" });
}}
/>
{/* aria-disabled, not disabled: focus stays while checking. */}
<button
type="button"
aria-disabled={state.status === "checking"}
onClick={handleCheck}
>
{BUTTON[state.status]}
</button>
<p role="status">{statusText(state)}</p>
{state.status === "failed" ? (
<p role="alert">{state.reason}</p>
) : null}
</section>
);
}Press “Verify account” and the button reads “Checking…” while a status line says the bank is being asked. A refusal replaces both with an alert that says what to check, and the button offers “Try again”. Retry, and the alert goes the moment checking starts. Edit the IBAN while a check is out, and its answer is dropped:
| Moment | The status | On screen |
|---|---|---|
| “Verify account” pressed | checking, attempt 1 | Checking… |
| The IBAN is edited | idle, attempt 1 | Verify account |
| The bank says yes to attempt 1 | idle, attempt 1 | Verify account |
Living with one status
A status makes the states explicit, and most of the remaining work is in their edges.
A union, a reducer, or a state chart
For a straight line of states, a union in useState is enough: one setter, one value. A reducer earns its place once there are rules about which moves are allowed, as here. When states nest, such as a check that can also wait for a manual review with its own steps, a state chart library describes the structure better than a hand-written switch.
A new state, found everywhere it matters
Add a fifth state, such as a check the bank has sent for manual review, and TypeScript lists every place that has to decide what it looks like. The record of button labels and the status line’s switch both stop compiling until the new state has an answer. The flags had no such list. A new flag compiled everywhere, and the screens it broke were found by customers.
Answers that arrive late
The attempt number keeps the screen right when answers arrive out of order. It doesn’t stop the request. Pass an AbortSignal to the request as well, aborted on an edit, so the browser stops waiting for an answer nobody will read.
Messages that match the state
Each state has its own words. Checking and verified are read politely from a status region; a failure is an alert, read at once, and says what to check rather than only that it failed. The button uses aria-disabled while checking, not disabled, because a focused button that becomes disabled loses its focus, and a keyboard or screen reader user loses their place with it.
Testing the rules without a screen
The reducer is a pure function, so its rules can be tested with plain values, and the component test only has to prove the screen follows. A bank the test answers by hand holds the checking state open for as long as the assertions need:
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { BankAccount } from "./bank-account";
import { INITIAL, verify } from "./verification";
const IBAN = "DE89 3704 0044 0532 0130 00";
/** A bank the test answers when it chooses. */
function heldBank() {
let answer = (_ok: boolean) => {};
const checkAccount = () =>
new Promise<{ last4: string }>((resolve, reject) => {
answer = (ok) =>
ok
? resolve({ last4: "3000" })
: reject(new Error("Unknown IBAN"));
});
return { checkAccount, answer: (ok: boolean) => answer(ok) };
}
test("the reducer drops a second check and a late answer", () => {
const checking = verify(INITIAL, { type: "check", attempt: 1 });
expect(verify(checking, { type: "check", attempt: 2 })).toBe(
checking,
);
const edited = verify(checking, { type: "edited" });
expect(
verify(edited, { type: "succeeded", attempt: 1, last4: "3000" }),
).toEqual(edited);
});
test("checking, then a failure, never shows both", async () => {
const user = userEvent.setup();
const bank = heldBank();
render(<BankAccount checkAccount={bank.checkAccount} />);
await user.type(
screen.getByRole("textbox", { name: "IBAN" }),
IBAN,
);
await user.click(
screen.getByRole("button", { name: "Verify account" }),
);
expect(screen.getByRole("status")).toHaveTextContent(
"Checking the account with your bank…",
);
expect(screen.queryByRole("alert")).toBeNull();
await act(async () => bank.answer(false));
expect(screen.getByRole("alert")).toHaveTextContent(
"The bank didn’t recognise this IBAN.",
);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
await user.click(screen.getByRole("button", { name: "Try again" }));
expect(screen.queryByRole("alert")).toBeNull();
await act(async () => bank.answer(true));
expect(screen.getByRole("status")).toHaveTextContent(
"Verified: payouts go to account ending 3000.",
);
});
test("an answer for an edited IBAN is ignored", async () => {
const user = userEvent.setup();
const bank = heldBank();
render(<BankAccount checkAccount={bank.checkAccount} />);
const field = screen.getByRole("textbox", { name: "IBAN" });
await user.type(field, IBAN);
await user.click(
screen.getByRole("button", { name: "Verify account" }),
);
await user.type(field, "1");
await act(async () => bank.answer(true));
expect(screen.getByRole("status")).toBeEmptyDOMElement();
expect(
screen.getByRole("button", { name: "Verify account" }),
).toBeVisible();
});All three pass. With the three flags, the retry in the second test would still find the old alert on screen.
One status from the server
When the check runs in a Server Function, return the same kind of union, { ok: true, last4 } or { ok: false, reason }. Plain objects cross the boundary as they are, and the client maps each one to a single event.
React 19 keeps the pending part for you
useActionState returns the last result with an isPending flag React maintains itself. The status then becomes a derived value, isPending ? "checking" : result.status, and the result type is the union above without its checking member. It queues a second submission and runs it after the first, so a double click still checks twice unless the action skips a repeat. The React Compiler changes nothing here; the pattern is about which values exist, not how often they are computed.
When separate booleans are right
Flags that describe independent facts stay separate. Whether a panel is expanded and whether its sound is muted can be true in any combination, and every combination is a real screen. Merging them into one status would create states such as “expanded and muted” for no gain.
A single boolean for a process with two states is a status already. Reach for the union when a third state arrives, or when two flags start needing to be set together.
How Turtlekit’s Button takes one status
Button takes a single status of idle, pending, success or error, and draws the spinner, the check and the labels from it. The page maps its own union onto it, and shows a failure in a Callout with announce:
// The check's actions with Turtlekit: the page's one status maps onto
// Button's status, and a failure is a Callout that announces itself.
import { Button, Callout, Stack } from "turtlekit";
import type { Verification } from "./verification";
const BUTTON_STATUS = {
idle: "idle",
checking: "pending",
verified: "success",
failed: "error",
} as const;
export function VerifyAccount({
state,
onCheck,
}: {
state: Verification;
onCheck: () => void;
}) {
return (
<Stack gap="items">
<Button
status={BUTTON_STATUS[state.status]}
pendingLabel="Checking…"
successLabel="Verified"
errorLabel="Try again"
onClick={onCheck}
>
Verify account
</Button>
{state.status === "failed" ? (
<Callout
tone="danger"
title="We couldn’t verify this account"
announce
>
{state.reason}
</Callout>
) : null}
</Stack>
);
}While pending, the button keeps its focus and ignores clicks, and its label stays announced. When nothing else on the page needs the status, AsyncButton runs the action and keeps the status itself.
Built on this pattern in Turtlekit: Button, Code block, Icon button and Newsletter signup.
When the states blur
These three symptoms point back to a status that isn’t quite one value yet.
A spinner and an error show together
Two flags describe one process. Replace them with one status, and make starting a check move to the checking state, which has no error to show.
An old answer overwrites a newer one
The reducer accepts answers without asking which attempt they belong to. Tag each request and drop answers whose attempt is no longer current.
TypeScript says reason doesn’t exist on the state
The code reads reason before narrowing. Check state.status === "failed" first; inside that branch the field exists.
Name the states first
Most bugs in an async screen are not in the request. They are in the screen’s idea of where the request is, spread across flags that each tell part of the story. Writing the states down as one type is a design decision as much as a coding one. It asks what the screen shows in each, including the ones nobody drew, such as an answer that arrives after the account was edited. The type then keeps that answer long after the conversation where it was decided.