React Hook Form 05 ๐ Handling Submission โ handleSubmit and the onSubmit callback
The handleSubmit function is the gatekeeper between the form’s internal validation and the application’s submission logic. It receives the onSubmit callback, runs the validation against every registered field, and invokes the callback only when the validation passes. The callback receives the typed form data, and the optional second callback receives the errors when the validation fails . The wrapper prevents the native form submission, so the page does not reload, and the SPA behavior is preserved without the developer writing the event.preventDefault() call.
The submission lifecycle is more than the isSubmitting flag. The formState tracks the isSubmitting, the isSubmitted, the isSubmitSuccessful, and the submitCount. The isSubmitting is true while the onSubmit callback’s promise is pending, the isSubmitted is true after the submission attempt completes regardless of the outcome, the isSubmitSuccessful is true only when the validation passed and the callback did not throw, and the submitCount increments on every attempt . The distinction between isSubmitted and isSubmitSuccessful is the mechanism that separates “the user tried” from “the form succeeded.”
Key point: handleSubmit(onValid, onInvalid?) validates the form and calls the onValid callback only when the validation passes. The onInvalid callback receives the FieldErrors object. The isSubmitting stays true until the promise returned by the onSubmit callback settles, so the async work must be awaited or returned. The handleSubmit does not swallow the errors thrown inside the callback, and the setError is the mechanism that maps the server-side errors to the fields.
Why the handleSubmit wrapper exists
The native submission problem. A native <form> submits to the action URL and reloads the page. The handleSubmit calls event.preventDefault() internally, and the page does not reload . The wrapper is the mechanism that keeps the SPA behavior, and the prevention is the reason the form does not navigate away.
The validation problem. The handleSubmit runs the validation before it calls the callback. The resolver or the register rules run, and the errors populate the formState.errors. The onValid callback is called only if the validation passes, and the onInvalid callback is called if it fails . The separation is the mechanism that keeps the invalid data out of the application.
The async state problem. The isSubmitting is true while the onValid callback’s promise is pending. The callback must return a promise for the state to be tracked, and the async function is the mechanism that returns the promise . A synchronous callback sets the isSubmitting to true and then immediately to false, and the spinner never appears.
The error problem. The handleSubmit does not swallow the errors that occur inside the onSubmit callback. If the callback throws or its promise rejects, the handleSubmit rethrows the error and sets isSubmitSuccessful to false . The callback must catch the error and call the setError to display the server-side errors, and the setError is the mechanism that maps the server errors to the form fields .
The double-submission problem. The handleSubmit does not block a second submission while the first one is still pending. The isSubmitting flag is the mechanism that disables the submit button, and the check at the start of the handler is the alternative . The double-submission guard is the developer’s responsibility.
a. The handleSubmit signature and the valid/invalid paths
The handleSubmit function accepts the onValid callback and the optional onInvalid callback. The onValid receives the typed data and the event, and the onInvalid receives the FieldErrors object and the event. The function returns a submit handler that the form’s onSubmit prop receives.
// The handleSubmit with the valid and invalid callbacks
import { useForm, type SubmitHandler, type SubmitErrorHandler } from "react-hook-form";
type LoginFormValues = {
email: string;
password: string;
};
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginFormValues>({
defaultValues: { email: "", password: "" },
});
// The onValid callback receives the typed data.
const onSubmit: SubmitHandler<LoginFormValues> = (data) => {
console.log("Valid:", data);
};
// The onInvalid callback receives the errors.
const onError: SubmitErrorHandler<LoginFormValues> = (errors) => {
console.log("Invalid:", errors);
};
return (
<form onSubmit={handleSubmit(onSubmit, onError)}>
<input {...register("email")} />
<input {...register("password")} type="password" />
<button type="submit">Log in</button>
</form>
);
}
The SubmitHandler<FormValues> is the explicit type for the valid callback, and the SubmitErrorHandler<FormValues> is the type for the invalid callback. The inline arrow function is the equivalent, and the types document the intent. The onInvalid callback is optional, but the explicit handler is the mechanism for the focus management, the analytics, and the toast notification .
The handleSubmit also accepts the event, and the callback receives the event as the second argument. The event is the mechanism for the stopPropagation call when the form is nested inside another form, such as in a modal .
// The nested form with the stopPropagation
<form onSubmit={(event) => {
event.stopPropagation();
handleSubmit(onInnerSubmit)(event);
}}>
{/* ... */}
</form>
The handleSubmit can also be called directly without the form event, which is the mechanism for the submit button outside the form or the keyboard shortcut. The handleSubmit(onSubmit)() without the event does not call preventDefault, and the direct call is the mechanism for the programmatic submission .
b. The async submission and the submit state
The async submission is the async callback that returns a promise. The isSubmitting is true while the promise is pending, and the isSubmitSuccessful is true after the promise resolves without the errors.
// The async submission
const onSubmit = async (data: LoginFormValues) => {
// The isSubmitting stays true until this promise settles.
try {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(data),
});
if (!response.ok) {
const body = await response.json();
// The server error is mapped to the form.
setError("email", { type: "server", message: body.message });
return;
}
// The success path.
navigate("/dashboard");
} catch (error) {
// The network error is mapped to the root.
setError("root.server", { type: "network", message: "Check your connection." });
}
};
The isSubmitting is the mechanism that disables the button, and the isSubmitSuccessful is the mechanism that shows the success message. The submitCount is the number of the attempts, and the isSubmitted is the state that is true after the attempt regardless of the outcome .
// The submit state in the UI
function SubmitButton() {
const {
formState: { isSubmitting, isSubmitSuccessful, submitCount },
} = useForm();
return (
<>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Logging in..." : "Log in"}
</button>
{isSubmitSuccessful && <p>Logged in successfully.</p>}
{submitCount > 0 && !isSubmitSuccessful && <p>Attempt {submitCount}</p>}
</>
);
}
The setError is the mechanism that maps the server-side errors to the fields. The function accepts the field name and the error object, and the error object has the type, the message, and the optional ref . The root.server key is the mechanism for the errors that do not belong to any field, such as the network errors or the general API failures .
// The root error display
{errors.root?.server && (
<p role="alert">{errors.root.server.message}</p>
)}
The reset after the successful submission is the mechanism that clears the form. The reset accepts the new default values or the undefined for the original defaults, and the keepValues option is the mechanism that keeps the current values while resetting the errors.
Complete Example Session
// ============================================
// PART 1: THE BASIC SUBMISSION
// ============================================
import { useForm } from "react-hook-form";
function LoginForm() {
const { register, handleSubmit } = useForm({
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
<input {...register("password")} type="password" />
<button type="submit">Log in</button>
</form>
);
}
// ============================================
// PART 2: THE INVALID CALLBACK
// ============================================
const onInvalid = (errors) => {
console.log("Invalid:", errors);
};
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
{/* ... */}
</form>
// ============================================
// PART 3: THE ASYNC SUBMISSION
// ============================================
const onSubmit = async (data) => {
// The isSubmitting stays true until this promise settles.
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(data),
});
};
// ============================================
// PART 4: THE SUBMIT STATE
// ============================================
function SubmitButton() {
const {
formState: { isSubmitting, isSubmitSuccessful },
} = useForm();
return (
<button disabled={isSubmitting}>
{isSubmitting ? "Loading..." : "Submit"}
</button>
);
}
// ============================================
// PART 5: THE SERVER ERROR
// ============================================
const onSubmit = async (data) => {
try {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(data),
});
if (!response.ok) {
const body = await response.json();
setError("email", { type: "server", message: body.message });
}
} catch (error) {
setError("root.server", { type: "network", message: "Check your connection." });
}
};
// ============================================
// PART 6: THE RESET AFTER THE SUCCESS
// ============================================
const onSubmit = async (data) => {
await fetch("/api/login", { method: "POST", body: JSON.stringify(data) });
reset(); // The form is reset to the defaultValues.
};
// ============================================
// PART 7: THE SUBMIT COUNT
// ============================================
function SubmitCount() {
const { formState: { submitCount } } = useForm();
return <p>The form has been submitted {submitCount} times.</p>;
}
// ============================================
// PART 8: THE NESTED FORM
// ============================================
<form onSubmit={(event) => {
event.stopPropagation();
handleSubmit(onInnerSubmit)(event);
}}>
{/* ... */}
</form>
// ============================================
// PART 9: THE DIRECT CALL
// ============================================
<button type="button" onClick={handleSubmit(onSubmit)}>
Save
</button>
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The handleSubmit wraps the submit callback.
// [ ] The onValid callback has the typed data.
// [ ] The onInvalid callback receives the errors.
// [ ] The onSubmit callback is async and returns the promise.
// [ ] The isSubmitting disables the button.
// [ ] The setError maps the server-side errors.
// [ ] The reset clears the form after the success.
The ten parts covered the basic submission, the invalid callback, the async submission, the submit state, the server error, the reset, the submit count, the nested form, the direct call, and the checklist. The through-line is that the handleSubmit is the validation boundary, and the submit state is the async lifecycle.
Quick Reference
The handleSubmit signature
| Parameter | Type | Purpose |
|---|---|---|
onValid | SubmitHandler<FormValues> | The callback for the valid data |
onInvalid | SubmitErrorHandler<FormValues> | The callback for the errors |
The submit state
| Property | Type | Purpose |
|---|---|---|
isSubmitting | boolean | True while the async callback is pending |
isSubmitted | boolean | True after the submission attempt, regardless of the outcome |
isSubmitSuccessful | boolean | True after the successful submission |
submitCount | number | The number of the attempts |
The error mapping
| API | Purpose |
|---|---|
setError(name, error) | Map the server error to the field |
setError("root.server", error) | Map the general error to the root |
clearErrors(name) | Remove the field error |
reset(values) | Reset the form and the errors |
The server error object
| Property | Type | Purpose |
|---|---|---|
type | string | The error category |
message | string | The error message |
ref | HTMLElement | The optional element reference |
Best Practices
โ Do This:
// Use the async callback
const onSubmit = async (data) => { await fetch(...); }; // โ
// Use the onInvalid callback for the failure path
handleSubmit(onSubmit, onInvalid) // โ
// Map the server errors with setError
setError("email", { message: body.message }); // โ
// Use the isSubmitting to disable the button
<button disabled={isSubmitting}>Submit</button> // โ
โ Don’t Do This:
// Use the synchronous callback with the spinner
const onSubmit = (data) => { fetch(...); }; // the isSubmitting is not tracked // โ
// Throw the server error without the setError
if (!response.ok) throw new Error("..."); // the error is not displayed // โ
// Forget the reset after the success
await fetch(...); // the form keeps the values // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The spinner never appears | The callback is synchronous | Use the async callback |
| The server error is not displayed | The setError is not called | Map the error with setError |
| The form is not reset after the success | The reset is missing | Call the reset after the success |
The isSubmitSuccessful is always false | The callback throws | Catch the error and set the field error |
| The native page reload happens | The handleSubmit is not used | Wrap the callback with handleSubmit |
Real-World Examples
1. The basic submission
handleSubmit((data) => console.log(data))
2. The invalid callback
handleSubmit(onSubmit, onInvalid)
3. The async submission
const onSubmit = async (data) => { await fetch(...); };
4. The isSubmitting
const { formState: { isSubmitting } } = useForm();
5. The isSubmitSuccessful
const { formState: { isSubmitSuccessful } } = useForm();
6. The submit count
const { formState: { submitCount } } = useForm();
7. The setError
setError("email", { message: body.message });
8. The root error
setError("root.server", { message: "Network error" });
9. The reset
reset();
10. The direct call
onClick={handleSubmit(onSubmit)}
Visual
The handleSubmit Flow
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE handleSubmit FLOW โ
โ โ
โ The form is submitted โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The validation runs โ The resolver or the register rulesโ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโดโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โ โ Valid โ โInvalid โ โ
โ โ โ โ โ โ
โ โonValid โ โonInvalidโ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Async Submit State
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE ASYNC LIFECYCLE โ
โ โ
โ The submit callback is called โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ isSubmitting: true โ The button is disabled โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The promise settles โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโดโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โ โSuccess โ โFailure โ โ
โ โ โ โ โ โ
โ โisSubmitโ โsetErrorโ โ
โ โSuccess โ โ โ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Server Error Mapping
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE SERVER ERROR MAPS TO THE FIELD โ
โ โ
โ The server response โ
โ { "message": "The email is taken." } โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ setError("email", โ The error is stored in the โ
โ โ { message }) โ formState.errors.email โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The error is โ The UI renders it with the โ
โ โ displayed โ other field errors โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
handleSubmit | The validation boundary, prevents the native submission |
onValid | The callback for the valid data |
onInvalid | The callback for the errors |
isSubmitting | True while the async callback is pending |
isSubmitted | True after the attempt, regardless of the outcome |
isSubmitSuccessful | True after the successful submission |
setError | Maps the server error to the field |
reset | Resets the form and the errors |
Key takeaways:
- The
handleSubmitprevents the native submission and validates before it calls the callback. TheonValidcallback receives the typed data, and theonInvalidcallback receives the errors. The separation is the mechanism that keeps the invalid data out of the application . - The async callback is the mechanism for the submit state. The
isSubmittingstays true until the promise settles, and theisSubmitSuccessfulis true after the successful submission. A synchronous callback does not track the state . - The
setErrormaps the server errors to the fields. The callback catches the server error and calls thesetErrorwith the field name and the error object. Theroot.serverkey is the mechanism for the general errors . - The
resetclears the form after the success. The function restores the default values and clears the errors. ThekeepValuesoption keeps the current values while resetting the errors. - The
isSubmittedand theisSubmitSuccessfulare different states. TheisSubmittedis true after the attempt, and theisSubmitSuccessfulis true only when the validation passed and the callback did not throw .
Remember: The handleSubmit is the validation boundary. It prevents the native submission, runs the validation, and calls the onValid callback only if the validation passes. The onInvalid callback is the failure path. The async callback is the mechanism for the isSubmitting state, and the setError is the mechanism for the server errors. The reset is the cleanup after the success.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!