Skip to main content

Forms

In this chapter, you learn how to handle forms in React.

What you'll learn in this chapter​

  • The concept and implementation of controlled components
  • How to handle various form elements
  • Form validation
  • Managing submission state
info

This chapter uses the project you created in Chapter 3. Start the development server with npm run dev and study while writing code.

Controlled components​

In React, the "controlled component" pattern, where a form's value is managed by State, is common.

Controlled components vs uncontrolled components

There are two approaches to managing a form.

TypeValue managementUse case
Controlled componentReact (State)Validation, conditional input
Uncontrolled componentDOM (ref)Simple forms, file input

In this chapter you learn about controlled components. Uncontrolled components (useRef) are covered in Chapter 12.

function ControlledInput() {
// Manage the input value with State
const [value, setValue] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}

return (
// Controlled component: specify value and onChange together
// value={value} → display the State value
// onChange={handleChange} → update State on input
<input
value={value}
onChange={handleChange}
/>
)
}

Basic form elements​

Text input​

function TextInput() {
const [name, setName] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

return (
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
type="text"
value={name}
onChange={handleChange}
/>
</div>
)
}

Text area​

function TextAreaInput() {
const [message, setMessage] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
}

return (
<div>
<label htmlFor="message">Message:</label>
<textarea
id="message"
value={message}
onChange={handleChange}
rows={4}
/>
</div>
)
}

Select box​

function SelectInput() {
const [country, setCountry] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setCountry(e.target.value)
}

return (
<div>
<label htmlFor="country">Country:</label>
<select
id="country"
value={country}
onChange={handleChange}
>
<option value="">Please select</option>
<option value="jp">Japan</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
</select>
</div>
)
}

Checkbox​

function CheckboxInput() {
const [agreed, setAgreed] = useState(false)

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// For a checkbox, use checked instead of value
setAgreed(e.target.checked)
}

return (
<div>
<label>
<input
type="checkbox"
checked={agreed} // control with checked, not value
onChange={handleChange}
/>
I agree to the terms of service
</label>
</div>
)
}

Radio buttons​

function RadioInput() {
const [gender, setGender] = useState('')

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setGender(e.target.value)
}

return (
<div>
<label>
<input
type="radio"
name="gender"
value="male"
// Determine the selected state with checked={gender === 'male'}
// The radio button whose value matches the State is checked
checked={gender === 'male'}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={gender === 'female'}
onChange={handleChange}
/>
Female
</label>
</div>
)
}

Managing multiple form elements​

Managing with an object​

// Avoid colliding with the browser's built-in FormData type
type ContactFormData = {
name: string
email: string
message: string
}

function ContactForm() {
// Manage multiple input values together in one object
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
message: ''
})

// Use a union type to handle both input and textarea
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
// Get the input field's name attribute with e.target.name
const { name, value } = e.target
// [name]: value is a "dynamic property name"
// If name="email", it means the same as { ...prev, email: value }
setFormData(prev => ({ ...prev, [name]: value }))
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log(formData)
}

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
{/* name="name" must match the property name of formData */}
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="message">Message:</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
/>
</div>
<button type="submit">Submit</button>
</form>
)
}

Let's try it: a profile form​

Create a form where you can enter a name, email, and self-introduction, and when you press the submit button, display the contents in the console.

Hint
  1. Define a ProfileData type and manage it with object State
  2. Set a name attribute on each input
  3. Update everything together with handleChange
  4. Don't forget e.preventDefault() in handleSubmit
Answer and explanation
import { useState } from 'react'
import './App.css'

type ProfileData = {
name: string
email: string
bio: string
}

function ProfileForm() {
const [formData, setFormData] = useState<ProfileData>({
name: '',
email: '',
bio: ''
})

const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log('Submitted data:', formData)
}

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="bio">Self-introduction:</label>
<textarea
id="bio"
name="bio"
value={formData.bio}
onChange={handleChange}
rows={4}
/>
</div>
<button type="submit">Submit</button>
</form>
)
}

export default function App() {
return <ProfileForm />
}

You can reuse handleChange even as the number of input fields grows, thanks to the dynamic property [name]: value.

Validation​

Simple validation​

type FormErrors = {
name?: string
email?: string
}

function ValidatedForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [errors, setErrors] = useState<FormErrors>({})

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
}

// Validation function: check the input values and set errors
// Return value true → validation succeeded, false → failed
const validate = (): boolean => {
const newErrors: FormErrors = {}

// Check after removing leading/trailing whitespace with trim()
if (!name.trim()) {
newErrors.name = 'Name is required'
}

if (!email.trim()) {
newErrors.email = 'Email is required'
} else if (!/\S+@\S+\.\S+/.test(email)) {
// Check the email format with a regular expression
newErrors.email = 'Please enter a valid email address'
}

setErrors(newErrors)
// Return true if there are no errors (the object has 0 keys)
return Object.keys(newErrors).length === 0
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
console.log('Submit:', { name, email })
}
}

return (
<form onSubmit={handleSubmit}>
<div>
<input
value={name}
onChange={handleNameChange}
placeholder="Name"
/>
{errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<input
type="email"
value={email}
onChange={handleEmailChange}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<button type="submit">Submit</button>
</form>
)
}

Real-time validation​

function RealtimeValidation() {
const [email, setEmail] = useState('')
// touched: whether the user has ever focused the input field
// Used to avoid showing an error on the initial display
const [touched, setTouched] = useState(false)

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.target.value)
}

// onBlur: fires when focus leaves the input field
const handleBlur = () => {
setTouched(true)
}

const isValid = /\S+@\S+\.\S+/.test(email)
// Show the error only when all three conditions are met
// 1. touched: has been focused
// 2. !isValid: the format is invalid
// 3. email.length > 0: something is entered
const showError = touched && !isValid && email.length > 0

return (
<div>
<input
type="email"
value={email}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Email address"
/>
{showError && (
<span className="error">Please enter a valid email address</span>
)}
</div>
)
}

Let's try it: add validation​

Add validation to the profile form that displays the error message "Name is required" when the name is empty.

Hint
  1. Add a State errors
  2. Check whether the name is empty in the validate function
  3. If there's an error, save it in State and display it
  4. Clearing the error on input improves UX
Answer and explanation
import { useState } from 'react'
import './App.css'

type ProfileData = {
name: string
email: string
bio: string
}

type FormErrors = {
name?: string
}

function ProfileFormWithValidation() {
const [formData, setFormData] = useState<ProfileData>({
name: '',
email: '',
bio: ''
})
const [errors, setErrors] = useState<FormErrors>({})

const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
// Clear the error on input
if (errors[name as keyof FormErrors]) {
setErrors(prev => ({ ...prev, [name]: undefined }))
}
}

const validate = (): boolean => {
const newErrors: FormErrors = {}
if (!formData.name.trim()) {
newErrors.name = 'Name is required'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (validate()) {
console.log('Submitted data:', formData)
}
}

return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
/>
{errors.name && <span style={{ color: 'red' }}>{errors.name}</span>}
</div>
{/* Other fields... */}
<button type="submit">Submit</button>
</form>
)
}

export default function App() {
return <ProfileFormWithValidation />
}

We do the required check in the validate function, and if there's an error, save it in State and display it. Clearing the error on input makes the message disappear as soon as the user fixes the field.

Accessibility-conscious forms​

Forms involve a lot of user interaction, and without accessibility (a11y) considerations, screen reader users end up filling them in without being able to read either the labels or the errors. When implementing a validated form, keep at least these in mind.

  • Associate <label> with the input via htmlFor / id: This lets screen readers read out the label, and moves focus to the input when the label is clicked
  • Associate the error message with the input via aria-describedby: Screen readers read out the error content
  • Use aria-invalid="true" when the input is invalid: Screen readers notify the user of "invalid input"
function AccessibleInput() {
const [email, setEmail] = useState('')
const [error, setError] = useState<string | null>(null)

return (
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={error !== null}
aria-describedby={error ? 'email-error' : undefined}
required
/>
{error && (
<p id="email-error" role="alert" style={{ color: 'red' }}>
{error}
</p>
)}
</div>
)
}
info

Adding role="alert" makes a screen reader read out the message the moment it's displayed. This is suitable for notifying validation errors. For required fields, it's helpful to indicate them not only with a visual * but with both the required attribute and text inside the <label> (e.g., "(required)").

Managing submission state​

function SubmitForm() {
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

// Handle asynchronous processing with async/await
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true) // turn on the submitting flag

try {
const res = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
// fetch does not reject on 4xx/5xx server errors, so check ok
if (!res.ok) {
throw new Error(`Submission failed (${res.status})`)
}
setIsSuccess(true) // turn on the success flag
setName('') // clear the input field
} catch (error) {
console.error('Submission error:', error)
} finally {
// finally: always runs whether it succeeds or fails
setIsSubmitting(false) // turn off the submitting flag
}
}

if (isSuccess) {
return <p>Submission complete!</p>
}

return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={handleNameChange}
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}

Let's try it: managing submission state​

Create a form that displays "Submitting..." for 2 seconds when you press the submit button, then displays "Submission complete!".

Hint
  1. Add isSubmitting and isSuccess States
  2. Switch the state after 2 seconds with setTimeout
  3. Make the input and button disabled while submitting
  4. Display a "Submit again" button after completion
Answer and explanation
import { useState } from 'react'
import './App.css'

function SubmitStateForm() {
const [name, setName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)

const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}

const handleReset = () => {
setIsSuccess(false)
setName('')
}

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)

// Show completion after 2 seconds
setTimeout(() => {
setIsSubmitting(false)
setIsSuccess(true)
}, 2000)
}

if (isSuccess) {
return (
<div>
<p>Submission complete!</p>
<button onClick={handleReset}>
Submit again
</button>
</div>
)
}

return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={handleNameChange}
placeholder="Name"
disabled={isSubmitting}
/>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}

export default function App() {
return <SubmitStateForm />
}

We manage the submitting state with isSubmitting and the completion state with isSuccess. While submitting, we make the input and button disabled to prevent duplicate submissions.

The Actions pattern in React 19​

React 19 introduced "Actions" for writing asynchronous processing such as form submission concisely. Using useActionState / useFormStatus / useOptimistic, you no longer have to write the management of the submitting flag and error state yourself.

useActionState​

You can handle the submission processing and state management together. The return value of the submit function is held as state directly.

import { useActionState } from 'react'

type FormState = {
message: string
error: string | null
}

async function submitAction(_prevState: FormState, formData: FormData): Promise<FormState> {
const name = formData.get('name') as string

if (!name) {
return { message: '', error: 'Please enter your name' }
}

await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})

return { message: `${name}, your submission is complete`, error: null }
}

function ActionForm() {
const [state, formAction, isPending] = useActionState(submitAction, {
message: '',
error: null,
})

return (
<form action={formAction}>
<input name="name" placeholder="Name" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.message && <p>{state.message}</p>}
</form>
)
}
  • Just pass a function to the form's action attribute, and it's called on submission
  • isPending is managed automatically, so you don't need to manage isSubmitting yourself
  • The submit function only needs to receive "the previous state" and FormData and return the new state

useFormStatus​

Use it when you want to reference the submission state in a child component of the form, such as a submit button.

import { useFormStatus } from 'react-dom'

function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}

useFormStatus reads the state of the nearest parent <form> of the caller. Without writing logic in the entire form, you can complete the submitting display on the button component side.

useOptimistic​

Use it when you want to update the UI first without waiting for the server response. It's suitable for UIs where you want to give immediate feedback, such as a "like" button or sending a chat message.

import { useOptimistic, useState } from 'react'

function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount)
const [optimisticCount, addOptimistic] = useOptimistic(count, (current: number) => current + 1)

async function handleLike() {
addOptimistic(null) // update the UI first
const res = await fetch('/api/like', { method: 'POST' })
const data = await res.json()
setCount(data.count) // overwrite with the server's confirmed value
}

return (
<form action={handleLike}>
<button type="submit">Like ({optimisticCount})</button>
</form>
)
}
info

The value set with useOptimistic is automatically rolled back to the original value once the actual state is updated or the action fails. You don't need to write code to roll it back manually.

Choosing between this and the traditional pattern​

SituationRecommended pattern
Simple submission processinguseActionState
Switching only the appearance during submission in a childuseFormStatus
Optimistic UI updates are neededuseOptimistic
Complex validation is neededReact Hook Form + Zod
Uncontrolled components (file input, etc.)useRef + the traditional pattern

Actions can greatly reduce the amount of code in simple cases, but for complex forms where you want to validate each field in detail or handle dynamic fields, the traditional controlled + state pattern or a dedicated library is more suitable.

Form libraries​

For complex forms, consider using a dedicated library.

  • React Hook Form v7: Lightweight and high-performance, mainly uncontrolled components
  • TanStack Form: Developed by the same team as TanStack Query. A new option with excellent type safety and flexibility
  • Formik: Feature-rich with a track record (maintenance is not very active, so consider carefully before adopting it for new projects)
  • Zod + React Hook Form: Schema-based validation (covered in detail in the next chapter)
info

A form library saves you from building validation, error handling, and performance optimization yourself.

Summary​

  • Manage form values with State via controlled components
  • Manage multiple inputs together in an object
  • Run validation either on submission or in real time
  • Manage submission state (loading, success, error) appropriately
  • Consider using a library for complex forms

In the next chapter, you validate forms and external data in a type-safe way with Zod.