Handling Form Submission in React
How to handle form submission in React: async submit handlers, loading states, error display, and resetting the form after success.
I've written more React forms than I can count, and the submission logic is where things get messy fast. The controlled vs uncontrolled debate gets a lot of attention, but the submit handler itself is where most bugs live: missing preventDefault, no loading state, error messages that never clear.
Here's what I've settled on.
The basic shape
Every form submission in React starts the same way. Attach onSubmit to the <form> element, not to the button. Call e.preventDefault() first thing. Without that call, the browser submits the form natively and reloads the page.
function ContactForm() {
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const data = new FormData(e.currentTarget)
console.log(data.get('name'))
}
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" />
<button type="submit">Send</button>
</form>
)
}
One thing that trips people up: attaching onClick to the button instead of onSubmit to the form. That works for mouse clicks but keyboard users hitting Enter are left out. onSubmit on the form handles both.
Reading form values
There are two ways to get values out at submit time. Controlled inputs keep the values in state, so you read from state in the handler. Uncontrolled inputs let you read from the form when it submits.
For a simple form where you don't need to validate while typing, FormData is clean:
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const email = formData.get('email') as string
sendMessage({ name, email })
}
For forms with real-time validation or fields that affect other UI, controlled state gives you more to work with:
const [name, setName] = useState('')
const [email, setEmail] = useState('')
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
sendMessage({ name, email })
}
Async submission
Almost every real form submits to an API. Once the handler is async, you need to deal with loading and error states.
function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [status, setStatus] = useState<'idle' | 'loading' | 'error' | 'success'>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setStatus('loading')
setErrorMessage(null)
try {
await sendMessage({ name, email })
setStatus('success')
setName('')
setEmail('')
} catch (err) {
setStatus('error')
setErrorMessage(err instanceof Error ? err.message : 'Something went wrong')
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
name="name"
type="text"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
name="email"
type="email"
/>
{errorMessage && <p role="alert">{errorMessage}</p>}
{status === 'success' && <p>Message sent!</p>}
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Sending...' : 'Send'}
</button>
</form>
)
}
Disable the button while loading. A user clicking Submit twice can cause duplicate submissions, and disabling the button is the simplest guard.
Clear the error before each attempt. Otherwise an old error message sits there while the new request is in flight.
Reset the fields after success. If the user sends another message right away, they shouldn't have to clear the old values themselves.
A status enum over multiple booleans
I've seen forms with isLoading, isError, and isSuccess as separate booleans. The problem is that combinations like isLoading && isError are theoretically possible, and the render logic gets hard to reason about. A single status string with explicit states is cleaner. The set of valid states is obvious at a glance, and you can switch on it:
switch (status) {
case 'idle':
return <button type="submit">Send</button>
case 'loading':
return <button disabled>Sending...</button>
case 'success':
return <p>Sent!</p>
case 'error':
return <button type="submit">Try again</button>
}
This is a state machine, even if a small one. Each state has clear meaning and you can't accidentally be in two conflicting states at once.
React 19: useActionState
React 19 added useActionState, which packages most of this pattern. It takes an async action function and returns [state, action, isPending]. The form's action prop replaces onSubmit.
import { useActionState } from 'react'
type FormState = { error?: string; success?: boolean }
async function submitForm(prev: FormState, formData: FormData): Promise<FormState> {
try {
await sendMessage({
name: formData.get('name') as string,
email: formData.get('email') as string,
})
return { success: true }
} catch {
return { error: 'Failed to send. Try again.' }
}
}
function ContactForm() {
const [state, action, isPending] = useActionState(submitForm, {})
return (
<form action={action}>
<input name="name" type="text" />
<input name="email" type="email" />
{state.error && <p>{state.error}</p>}
{state.success && <p>Sent!</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
)
}
useActionState fits particularly well with Next.js Server Actions. The action function can run on the server, so there's no separate API route to write.
What changed
The main shift was treating submission state as a state machine rather than scattered booleans. Once I had a clear idle | loading | error | success model, the render logic became predictable and bugs like double submission or stale error messages went away.
The other thing: onSubmit on the form, not onClick on the button. Every time.
Newsletter
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.