Forms are deceptively complex. What seems like a simple task—collecting user input and submitting it to a server—quickly becomes a labyrinth of validation logic, error handling, loading states, and edge cases. The good news? The modern React ecosystem gives us powerful tools to handle all of this elegantly.
In this guide, we'll explore the current best practices for form handling in React, combining the progressive enhancement of Server Actions with React Hook Form and Zod validation.
Server Actions for Form Submission
Server Actions represent a paradigm shift in how we handle form submissions. Instead of setting up API routes and making fetch calls, we can write server-side logic directly and invoke it from forms using the standard HTML form action attribute.
'use server'
import { z } from 'zod'
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})
export async function submitContact(formData: FormData) {
const validatedFields = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
await saveToDatabase(validatedFields.data)
return { success: true }
}
The beauty of Server Actions is their progressive enhancement. If JavaScript fails to load, the form still works through the browser's native form submission.
Client-Side Validation with Zod
While Server Actions handle server-side validation, users expect instant feedback. Zod schemas can be shared between client and server, giving us a single source of truth:
// lib/validations/contact.ts
import { z } from 'zod'
export const contactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(10).max(5000),
})
export type ContactFormData = z.infer<typeof contactSchema>
React Hook Form: When You Need It
For complex forms with conditional fields, dynamic arrays, and performance requirements, React Hook Form becomes invaluable. It uses uncontrolled components, preventing re-renders on every keystroke:
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { contactSchema, type ContactFormData } from '@/lib/validations/contact'
import { submitContact } from './actions'
import { useTransition } from 'react'
export function ContactForm() {
const [isPending, startTransition] = useTransition()
const { register, handleSubmit, formState: { errors }, reset } = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
})
const onSubmit = (data: ContactFormData) => {
startTransition(async () => {
const formData = new FormData()
Object.entries(data).forEach(([key, value]) => formData.append(key, value))
const result = await submitContact(formData)
if (result.success) reset()
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} aria-invalid={errors.name ? 'true' : 'false'} />
{errors.name && <span role="alert">{errors.name.message}</span>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
)
}
Optimistic Updates and Pending States
React 19 introduces useOptimistic for instant feedback. Combined with useFormStatus, you can show pending states without blocking the UI:
'use client'
import { useOptimistic, useTransition } from 'react'
export function TodoList({ todos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
)
async function handleAdd(formData) {
addOptimisticTodo({ text: formData.get('text'), id: Date.now() })
await addTodoAction(formData)
}
return (
<form action={handleAdd}>
<input name="text" />
<button type="submit">Add</button>
</form>
)
}
File Uploads and Multipart Forms
Server Actions handle multipart form data naturally, but consider file size limits and progress indicators:
'use server'
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File
if (!file || file.size > 5 * 1024 * 1024) {
return { error: 'File too large (max 5MB)' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(`./uploads/${file.name}`, buffer)
return { success: true }
}
Key Takeaways
Use Server Actions as your foundation. They provide progressive enhancement and simplify the mental model.
Share validation schemas. Zod makes this seamless between client and server.
Add React Hook Form for complex scenarios. Dynamic fields, validation dependencies, and performance needs.
Provide immediate feedback. Optimistic updates and pending states make forms feel responsive.
Handle errors gracefully. Field-level errors are more actionable than generic messages.
