Runnable lesson · press Try it on the examples to edit and run them here.
Submitting a form usually means sending data to a server, which takes time and can fail. A good submit handler tracks the status and tells the user what is happening.
The submit flow
- Stop the default browser submit (
preventDefault). - Mark the form as submitting, and disable the button.
- Send the data (here we simulate a server).
- On success, show a message and often reset the form.
- On failure, keep the user’s input and show an error they can act on.
- Always leave the submitting state, success or not.
// A pretend server: fails if the message contains "fail"
function sendToServer(message) {
return new Promise((resolve, reject) => {
setTimeout(() => (message.includes("fail") ? reject(new Error("The server rejected your message.")) : resolve()), 700);
});
}
function App() {
const [message, setMessage] = React.useState("");
const [status, setStatus] = React.useState("idle"); // idle | submitting | success | error
const [error, setError] = React.useState("");
async function handleSubmit(event) {
event.preventDefault();
if (status === "submitting") return; // guard against double submit
setStatus("submitting");
setError("");
try {
await sendToServer(message);
setStatus("success");
setMessage(""); // reset the field
} catch (err) {
setStatus("error");
setError(err.message); // keep the text so the user can try again
}
}
return (
<form onSubmit={handleSubmit}>
<label>
Message
<input aria-label="Message" value={message} onChange={(e) => setMessage(e.target.value)} required />
</label>
<button type="submit" disabled={status === "submitting"}>
{status === "submitting" ? "Sending..." : "Send message"}
</button>
<div role="status">
{status === "success" && <p>Message sent</p>}
{status === "error" && <p style={{ color: "#c0392b" }}>{error}</p>}
</div>
</form>
);
}
Try typing fail in the message to see the error path. The text stays in the field so the user does not lose their work.
Why a status string?
A single status that is exactly one of four values cannot be in an impossible state, such as “submitting and success”. Several booleans can. See Multiple State Variables.
Real requests
In your own project the sendToServer call becomes a fetch:
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!res.ok) throw new Error("Request failed with status " + res.status);
Remember that fetch does not throw on HTTP errors, so check res.ok (Fetch POST JSON). Never send secrets or API keys from the browser; see API Security Fundamentals.
Disabled button or aria-disabled?
A disabled button cannot be focused, which can confuse keyboard users who lose their place. For long operations it is fine. For a form that is simply not valid yet, consider keeping the button enabled and explaining the problem when the user submits; see Validation Messages.
After success
Choose one:
- Reset the form (as above) and show a confirmation.
- Navigate to another page, such as a “Thank you” screen.
- Keep the form if the user is editing existing data, and show “Saved”.
Announce the result to assistive technology with role="status" (polite) or role="alert" (urgent), as in the example.
Key points
- Use
preventDefault, astatusstate, a disabled or busy button and a guard against double submits. - Keep the user’s input on failure and show an actionable message.
- Always validate again on the server; the client check is a convenience.