React Signup Flows Need a Small State Machine
Signup forms look simple until an email check, resend button, and slow network are involved. Then one screen can be showing three truths at once: the email is valid, the request is still running, and the user has already clicked resend.
I have found that the cleanest fix is not another boolean like isLoading. Give the flow a small state machine instead. It makes the UI easier to reason about, easier to test, and much less likely to ship confusing feedback.
The hidden problem is state ambiguity
A typical React component starts with something like this:
const [isChecking, setIsChecking] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [error, setError] = useState<string | null>(null);
These values seem harmless, but they can describe impossible combinations. What does isChecking: true and isVerified: true mean? Is the old check still visible while the new one runs? If the first response arrives after the second, which result wins?
This is where a temp mail so test account or a deliberately slow test endpoint becomes useful. It exposes race conditions that are easy to miss on a fast local connection. The issue is not the mailbox itself; it is that the frontend has no clear decision about which request owns the screen.
Model the flow explicitly
Start with states that describe user-visible decisions rather than implementation details:
type EmailState =
| { status: "idle"; value: string }
| { status: "checking"; value: string; requestId: number }
| { status: "verified"; value: string }
| { status: "rejected"; value: string; message: string };
Now the component cannot accidentally render a success message and an error message from the same state. The requestId also gives us a cheap way to ignore stale responses.
For a larger product, this pattern pairs nicely with invite flows without state drift. The same idea applies when the email check is only one step in a multi-screen flow.
A small TypeScript reducer
Reducers keep transitions visible. That is valuable when product requirements change every other week:
type Action =
| { type: "typed"; value: string }
| { type: "check-started"; requestId: number }
| { type: "check-passed"; requestId: number }
| { type: "check-failed"; requestId: number; message: string };
function emailReducer(state: EmailState, action: Action): EmailState {
switch (action.type) {
case "typed":
return { status: "idle", value: action.value };
case "check-started":
return { status: "checking", value: state.value, requestId: action.requestId };
case "check-passed":
return state.status === "checking" && state.requestId === action.requestId
? { status: "verified", value: state.value }
: state;
case "check-failed":
return state.status === "checking" && state.requestId === action.requestId
? { status: "rejected", value: state.value, message: action.message }
: state;
}
}
The stale-response guard is the important part. If request 8 finishes after request 9, it cannot overwrite the newer decision. It is a small detail, but it save lots of confusing support tickets.
Handle retries and cancellation
When a user edits the address, dispatch typed immediately. That clears old feedback and tells the user the current value has not been checked yet. On submit, increment a ref-based request counter and include it in every action.
An AbortController is still worth using to stop work that no longer matters, but cancellation alone is not a complete solution. Some APIs finish before cancellation reaches the server, so the request ID guard remains the final protection. Also disable only the action that is unsafe during a check; locking the whole form feels sluggish.
For test coverage, add a delayed-response case and a double-submit case. The less-guessy signup email tests offer a useful direction for capturing inbox evidence instead of asserting on timing guesses.
What to test
Keep the tests focused on transitions:
- typing after a rejection returns the state to
idle; - a successful current request renders the next step;
- an old success cannot replace a newer rejection;
- retrying preserves the input value;
- keyboard submission and the button use the same transition;
- a temporary or disposable address follows the product policy clearly.
If your test data includes the phrase tempail mail, treat it as ordinary input and verify that it does not accidentally bypass the same validation rules. Slightly odd inputs are often better at finding assumptions than polished fixtures.
Final checklist
Before shipping an email-dependent signup flow, ask:
- Can every visible combination be represented by one named state?
- Can an older response overwrite a newer one?
- Does editing the field clear outdated success or error copy?
- Do retries have a bounded, understandable behavior?
- Are the states tested with delayed and out-of-order responses?
A small state machine is not ceremony for its own sake. It gives React and TypeScript enough structure to keep async product behavior honest. The result is a signup experience that feels calmer for users and is much easier to extend when the next requirement arrives.
Top comments (0)