Forms are where a polished product can suddenly feel unreliable. A user submits an email, the button spins, and then… nothing useful happens. Maybe the request is still running. Maybe the server rejected the address. Maybe the response succeeded but the UI forgot to move on.
In React, this often starts with a harmless-looking isLoading boolean. Over time it becomes a bucket for validation, network work, success messages, retries, and failures. The component still works, but it is hard to explain and harder to recover.
The better approach is to give the form a small, explicit async state model. It makes the JavaScript easier to reason about, and it gives users a clearer product experience.
Why one loading boolean is not enough
Consider this common shape:
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
It looks simple until you ask a few questions:
- Should a second submit be blocked while client validation runs?
- Can the user edit the field after a server error?
- Does an old response still have permission to overwrite a newer attempt?
- What should the button say after success?
The boolean answers only one question: some work is happening. It does not say what work, for which attempt, or what action is safe next. That missing context is where confusing UI come from.
Model the form as explicit async states
For a small email form, a useful model might be:
idle -> validating -> submitting -> succeeded
| | |
+------ failed <---------+
The exact names are less important than the transitions. A user can edit an idle or failed form. A submitting form can disable its submit action. A succeeded form can show the next step instead of leaving a spinner on screen forever.
This is not a reason to install a state-machine library for every input. A string plus a small reducer is often enough. The key is that impossible combinations become harder to create, like showing “Try again” while the request is still pending.
A small React implementation
Here is a compact reducer for a form that requests an email verification message:
const initialState = {
status: "idle",
value: "",
message: "",
requestId: 0,
};
function formReducer(state, action) {
switch (action.type) {
case "edit":
return { ...state, value: action.value, status: "idle", message: "" };
case "submit":
return { ...state, status: "submitting", requestId: action.requestId, message: "" };
case "success":
return state.requestId === action.requestId
? { ...state, status: "succeeded", message: "Check your inbox." }
: state;
case "failure":
return state.requestId === action.requestId
? { ...state, status: "failed", message: action.message }
: state;
default:
return state;
}
}
The requestId is doing quiet but important work. If a user submits twice, a slower old response cannot replace the result of the newer attempt. Race conditions are less visible on a fast local connection, then become real in production. This little guard save a lot of head scratching.
The component can now render from status instead of combining several loosely related booleans:
const isSubmitting = state.status === "submitting";
const canSubmit = state.value.trim() !== "" && !isSubmitting;
<button type="submit" disabled={!canSubmit}>
{isSubmitting ? "Sending…" : "Send verification email"}
</button>
{state.message && <p role="status">{state.message}</p>}
The UI becomes more honest: the button communicates the current action, and the message has a stable place for assistive technology to discover it.
Keep validation and server state separate
Client validation and server failure are different kinds of feedback. “Enter a valid email” is actionable before a request. “This address is already registered” comes from the server. They should not compete for one generic error string.
I usually keep a small field error beside the async status:
const fieldError = !value.includes("@") ? "Check the email format." : "";
Then the submit handler exits before dispatching submit when fieldError exists. The server response can focus on what the user should do next. This division also makes tests much more direct.
For test fixtures, use a recognizable but non-sensitive value, even when the case is about a temporary inbox. A label such as fake e mail com can be kept as plain test data, but it should not become a misleading link or a real user-facing example. A small contract around email probes is useful too; see a test contract for email probes.
Test transitions, not just screenshots
React Testing Library tests are more valuable when they follow the user journey:
- Render the idle form.
- Submit an invalid value and verify no request starts.
- Submit a valid value and verify the pending label.
- Resolve the request and verify the success message.
- Reject a later request and verify retry remains possible.
Also test the stale-response case. Start request A, start request B, resolve A last, and assert that B's state remains visible. It feels slightly fussy, but this is exactly the bug that hides behind “works on my machine”.
The same thinking applies outside the browser. A release notification should have a clear contract about when it is emitted and what evidence it carries; treating release emails as a CI/CD gate is a useful example of making that boundary explicit.
Q&A: practical tradeoffs
Do I need a reducer for every form?
No. For a one-field form with no retry or race risk, local state is fine. Use a reducer when several events update related state or when the allowed transitions matter.
Should success be a permanent state?
Usually not. It can be a short-lived confirmation before navigation, or it can stay until the user edits the field. Choose the behavior that makes the next action obvious.
Is this overengineering?
It can be if the form has one synchronous action. For forms that send email, save data, or trigger payments, explicit states are cheaper than debugging a vague spinner later. The code is a little more words, but the product is less mysterious.
The main idea is simple: name the state your user is actually in. React then becomes the renderer of a clear contract, instead of a place where loading flags quietly accumulate. That shift makes features easier to ship, easier to test, and much nicer to recover from when the network does its usual weird stuff.
Top comments (0)