Skip to content

handleSubmit

Validate inputs and invoke your submit callback.

</> handleSubmit: UseFormHandleSubmit

This function will receive the form data if form validation is successful.

Props


NameTypeDescription
SubmitHandler(data: TFieldValues, e?: BaseSyntheticEvent) => TResult | Promise<TResult>A successful callback.
SubmitErrorHandler(errors: FieldErrors, e?: BaseSyntheticEvent) => unknown | Promise<unknown>An error callback.
RULES
  • Since v7.84.0 The promise returned by calling handleSubmit(onValid)(event) resolves with whatever onValid returns, instead of discarding it.

    const onValid = (data) => ({ id: data.id })
    const result = await handleSubmit(onValid)() // result: { id: string } | undefined
  • formState.isSubmitting stays true until the promise returned by onSubmit settles, so await (or return) your async work:

    const onSubmit = async (data) => {
    await fetch("/api", { method: "POST", body: JSON.stringify(data) }) // ✅ isSubmitting stays true until this resolves
    }
  • disabled inputs will appear as undefined values in form values. If you want to prevent users from updating an input and wish to retain the form value, you can use readOnly or disable the entire <fieldset />. Here is an example.

  • handleSubmit function will not swallow errors that occurred inside your onSubmit callback, so we recommend that you use try/catch blocks inside your async requests and handle those errors gracefully for your customers. Use setError inside the catch block to register a server-side error — this also ensures formState.isSubmitSuccessful is set to false.

    const onSubmit = async (data) => {
    // async request which may result error
    try {
    // await fetch()
    } catch (e) {
    setError("root.serverError", {
    type: e.status,
    message: e.message,
    })
    }
    }
    return <form onSubmit={handleSubmit(onSubmit)} />
  • To submit from a button outside the <form>, either link the button to the form with the native form attribute, or call the returned function directly:

    <form id="profile-form" onSubmit={handleSubmit(onSubmit)}>...</form>
    <button type="submit" form="profile-form">Save</button> // ✅ native form attribute
    <button type="button" onClick={handleSubmit(onSubmit)}>Save</button> // ✅ call handleSubmit directly
  • React's submit event bubbles through the component tree, including portals. If a form is rendered inside another form (for example in a modal), stop propagation so the outer form doesn't submit too:

    <form
    onSubmit={(event) => {
    event.stopPropagation()
    handleSubmit(onInnerSubmit)(event)
    }}
    />
  • For submitting with React Server Actions or useActionState, see Server Actions. To test your submit flow, see Testing Form.

Examples


Sync

import { useForm, SubmitHandler, SubmitErrorHandler } from "react-hook-form"
type FormValues = {
firstName: string
lastName: string
email: string
}
export default function App() {
const { register, handleSubmit } = useForm<FormValues>()
const onSubmit: SubmitHandler<FormValues> = (data) => console.log(data)
const onError: SubmitErrorHandler<FormValues> = (errors) =>
console.log(errors)
return (
<form onSubmit={handleSubmit(onSubmit, onError)}>
<input {...register("firstName")} />
<input {...register("lastName")} />
<input type="email" {...register("email")} />
<input type="submit" />
</form>
)
}

Async

import { useForm } from "react-hook-form"
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
function App() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm()
const onSubmit = async (data) => {
await sleep(2000)
if (data.username === "bill") {
alert(JSON.stringify(data))
} else {
alert("There is an error")
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label htmlFor="username">User Name</label>
<input placeholder="Bill" {...register("username")} />
<input type="submit" />
</form>
)
}

Resolver with transformed values

When a resolver (Zod, Yup, Joi, or custom) transforms the submitted data — so the output type differs from the field input type — use useForm's third generic, TTransformedValues, to tell TypeScript what handleSubmit will receive. Without it, the callback is typed as the input (field) type and you'll get errors accessing transformed properties.

import { useForm, SubmitHandler } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
// Example using Zod — the same pattern applies to any resolver whose
// output type differs from its input type (Yup, Joi, custom, etc.)
const schema = z
.object({
emailOrPhone: z.string().trim(),
})
.transform(({ emailOrPhone }) => ({
isEmail: emailOrPhone.includes("@"),
isPhone: /^\d+$/.test(emailOrPhone),
value: emailOrPhone,
}))
type FormInput = z.input<typeof schema> // { emailOrPhone: string }
type FormOutput = z.output<typeof schema> // { isEmail: boolean; isPhone: boolean; value: string }
export default function App() {
// 1st generic = field/input type (drives register, watch, etc.)
// 3rd generic = transformed output type (drives handleSubmit callback)
const { register, handleSubmit } = useForm<FormInput, unknown, FormOutput>({
resolver: zodResolver(schema),
})
const onSubmit: SubmitHandler<FormOutput> = (data) => {
console.log(data.isEmail, data.isPhone, data.value)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("emailOrPhone")} />
<input type="submit" />
</form>
)
}

Video


The following video tutorial explains the handleSubmit API in detail.

Tips


  • handleSubmit does not block a second submission while the first one is still pending. Disable the submit button with formState.isSubmitting, or check it at the start of your handler.

  • Use the second argument, onInvalid, to react to failed validation, for example to log analytics or open the section that contains the first error:

    handleSubmit(onValid, (errors) => {
    analytics.track("form_invalid", { fields: Object.keys(errors) })
    })
  • Transform the data inside onValid (or in your resolver) and not in the inputs. Inputs keep their raw values, and you send the shape your API needs.

  • When you call handleSubmit(onValid)() without an event, nothing calls preventDefault. Use this to submit from a keyboard shortcut or a timer.

Troubleshooting


onSubmit is never called and nothing happens

Why it happens

Validation failed. handleSubmit only calls your handler when every field is valid. If you do not render errors, the form looks like it ignores the click.

Fix

Render formState.errors, or pass a second callback to see what failed:

<form onSubmit={handleSubmit(onSubmit, (errors) => console.log(errors))}>

The page reloads and onSubmit never runs

Why it happens

handleSubmit(onSubmit) returns the submit handler. Wrapping it in an arrow function, as in onSubmit={() => handleSubmit(onSubmit)}, creates that handler but never calls it, so the browser submits the form natively and reloads the page.

Fix

Pass the result directly:

<form onSubmit={handleSubmit(onSubmit)}>

An error thrown in onSubmit is not shown to the user

Why it happens

If onSubmit throws (or its promise rejects), handleSubmit rethrows the error and sets isSubmitSuccessful to false. Nothing is added to formState.errors.

Fix

Catch the error and store it with setError under a root key:

const onSubmit = async (data) => {
try {
await save(data)
} catch {
setError("root.server", { message: "Could not save. Try again." })
}
}
{
errors.root?.server && <p role="alert">{errors.root.server.message}</p>
}