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 />
}

We set a name attribute on each input and update everything together with the dynamic property [name]: value. This way, you can reuse handleChange even as the number of input fields grows.

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 improves the user experience.

Accessibility-conscious forms

Forms involve a lot of user interaction, so accessibility (a11y) considerations directly affect quality. When implementing a validated form, keep at least the following three points 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>
)
}

There are three key points.

  • 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

For production applications, we recommend using a form library. It makes validation, error handling, and performance optimization easier.

info

Schema definition, type inference with z.infer, React Hook Form integration, and the Zod v4 notes are covered in detail with code examples in the next chapter, "Schema validation with Zod".

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.

Move on to Schema validation with Zod. You learn schema definition, type inference, React Hook Form integration, and runtime validation of API responses.