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
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.
| Type | Value management | Use case |
|---|---|---|
| Controlled component | React (State) | Validation, conditional input |
| Uncontrolled component | DOM (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
- Define a
ProfileDatatype and manage it with object State - Set a
nameattribute on each input - Update everything together with
handleChange - Don't forget
e.preventDefault()inhandleSubmit
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
- Add a State
errors - Check whether the name is empty in the
validatefunction - If there's an error, save it in State and display it
- 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 viahtmlFor/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>
)
}
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
- Add
isSubmittingandisSuccessStates - Switch the state after 2 seconds with
setTimeout - Make the input and button
disabledwhile submitting - 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'sactionattribute, and it's called on submission isPendingis managed automatically, so you don't need to manageisSubmittingyourself- The submit function only needs to receive "the previous state" and
FormDataand 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>
)
}
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
| Situation | Recommended pattern |
|---|---|
| Simple submission processing | useActionState |
| Switching only the appearance during submission in a child | useFormStatus |
| Optimistic UI updates are needed | useOptimistic |
| Complex validation is needed | React 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)
For production applications, we recommend using a form library. It makes validation, error handling, and performance optimization easier.
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.
What to read next
Move on to Schema validation with Zod. You learn schema definition, type inference, React Hook Form integration, and runtime validation of API responses.