React 19 Server Actions & useActionState: The Complete Form Handling Guide

Forms without the boilerplate. React 19 fundamentally changes how we handle forms — Server Actions and useActionState let you build type-safe, progressively enhanced forms that work even before JavaScript loads.
What You Will Learn
- How Server Actions replace API routes for form mutations
- Using
useActionStatefor form state management with pending states - Building progressively enhanced forms that work without JavaScript
- Type-safe server-side validation with Zod
- Optimistic UI updates with
useOptimistic - Real-world patterns: multi-step forms, file uploads, and error handling
Prerequisites
Before starting this tutorial, you should have:
- Node.js 20+ installed
- Basic knowledge of React and TypeScript
- Familiarity with Next.js App Router (pages, layouts, server components)
- A code editor like VS Code
Why Server Actions Change Everything
Before React 19, handling forms in React typically meant:
- Creating an API route (
/api/submit-form) - Using
useStatefor every form field - Writing
onChangehandlers for each input - Managing loading, error, and success states manually
- Calling
fetch()or a library to submit
That is a lot of plumbing for something as fundamental as a form. Server Actions eliminate most of this by letting you define server-side functions that React calls directly from the client — no API routes, no manual fetch calls, no separate state management.
// Before: The old way
'use client'
import { useState } from 'react'
export default function ContactForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
try {
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({ name, email }),
})
if (!res.ok) throw new Error('Failed')
} catch (err) {
setError('Something went wrong')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* ... lots of controlled inputs */}
</form>
)
}// After: With Server Actions
import { submitContact } from './actions'
export default function ContactForm() {
return (
<form action={submitContact}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>
)
}The "after" version works without JavaScript, has no client-side state, and the server function handles everything.
Step 1: Project Setup
Let us create a new Next.js 15 project with React 19:
npx create-next-app@latest react19-forms --typescript --tailwind --app --src-dir
cd react19-formsVerify you have React 19 in your package.json:
{
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "^15.0.0"
}
}Install Zod for server-side validation:
npm install zodStep 2: Your First Server Action
Create a file for your server actions. The "use server" directive at the top tells React this module only runs on the server:
// src/app/actions.ts
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
// This runs on the server — safe to access databases, secrets, etc.
console.log('Creating user:', { name, email })
// Simulate database insert
await new Promise((resolve) => setTimeout(resolve, 1000))
return { success: true, message: `User ${name} created!` }
}Now use it in a form. This is a Server Component — no "use client" needed:
// src/app/page.tsx
import { createUser } from './actions'
export default function HomePage() {
return (
<main className="max-w-md mx-auto mt-20 p-6">
<h1 className="text-2xl font-bold mb-6">Create Account</h1>
<form action={createUser} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full border rounded-lg px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded-lg px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700"
>
Create User
</button>
</form>
</main>
)
}This form works even with JavaScript disabled! The browser submits the form natively, and Next.js handles the Server Action on the server side.
Step 3: Adding useActionState for Rich Feedback
The basic form works, but there is no feedback — no loading state, no error messages, no success confirmation. This is where useActionState comes in.
useActionState is a React 19 hook that manages the lifecycle of a form action:
const [state, formAction, isPending] = useActionState(action, initialState)state— The current return value from the action (errors, success messages, etc.)formAction— A wrapped version of your action to pass to<form action={...}>isPending— Boolean indicating if the action is currently executing
First, update your Server Action to return structured state:
// src/app/actions.ts
'use server'
export type FormState = {
success: boolean
message: string
errors?: {
name?: string[]
email?: string[]
}
}
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
const name = formData.get('name') as string
const email = formData.get('email') as string
// Basic validation
const errors: FormState['errors'] = {}
if (!name || name.length < 2) {
errors.name = ['Name must be at least 2 characters']
}
if (!email || !email.includes('@')) {
errors.email = ['Please enter a valid email']
}
if (Object.keys(errors).length > 0) {
return { success: false, message: 'Validation failed', errors }
}
// Simulate database operation
await new Promise((resolve) => setTimeout(resolve, 1000))
return { success: true, message: `Welcome, ${name}!` }
}Notice the function signature changed — useActionState passes the previous state as the first argument, and formData as the second. This is different from a plain Server Action where formData is the only argument.
Now create the client component with useActionState:
// src/app/create-user-form.tsx
'use client'
import { useActionState } from 'react'
import { createUser, type FormState } from './actions'
const initialState: FormState = {
success: false,
message: '',
}
export default function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, initialState)
return (
<form action={formAction} className="space-y-4">
{/* Success message */}
{state.success && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
{state.message}
</div>
)}
{/* General error message */}
{!state.success && state.message && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
{state.message}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full border rounded-lg px-3 py-2"
aria-describedby="name-error"
/>
{state.errors?.name && (
<p id="name-error" className="text-red-600 text-sm mt-1">
{state.errors.name[0]}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full border rounded-lg px-3 py-2"
aria-describedby="email-error"
/>
{state.errors?.email && (
<p id="email-error" className="text-red-600 text-sm mt-1">
{state.errors.email[0]}
</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending ? 'Creating...' : 'Create User'}
</button>
</form>
)
}Update your page to use the new component:
// src/app/page.tsx
import CreateUserForm from './create-user-form'
export default function HomePage() {
return (
<main className="max-w-md mx-auto mt-20 p-6">
<h1 className="text-2xl font-bold mb-6">Create Account</h1>
<CreateUserForm />
</main>
)
}Step 4: Type-Safe Validation with Zod
Hardcoded validation strings are fragile. Let us use Zod for robust, type-safe validation:
// src/lib/schemas.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name must be under 50 characters'),
email: z
.string()
.email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
})
export type CreateUserInput = z.infer<typeof createUserSchema>Update the Server Action to use Zod:
// src/app/actions.ts
'use server'
import { createUserSchema } from '@/lib/schemas'
export type FormState = {
success: boolean
message: string
errors?: Record<string, string[]>
}
export async function createUser(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// Parse and validate with Zod
const result = createUserSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
})
if (!result.success) {
return {
success: false,
message: 'Please fix the errors below',
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
}
}
// result.data is now fully typed as CreateUserInput
const { name, email, password } = result.data
try {
// Simulate database insert
await new Promise((resolve) => setTimeout(resolve, 1000))
// In production: hash password, insert into database
console.log('Creating user:', { name, email })
return { success: true, message: `Account created for ${name}!` }
} catch (error) {
return { success: false, message: 'Failed to create account. Please try again.' }
}
}Always validate on the server, even if you also validate on the client. Client-side validation can be bypassed — server-side validation is your security boundary.
Step 5: Optimistic Updates with useOptimistic
For actions where you want instant feedback (like toggling a favorite or submitting a comment), React 19 provides useOptimistic:
// src/app/comments/comment-form.tsx
'use client'
import { useActionState, useOptimistic } from 'react'
import { addComment, type CommentState } from './actions'
type Comment = {
id: string
text: string
author: string
pending?: boolean
}
export default function CommentSection({
initialComments,
}: {
initialComments: Comment[]
}) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state: Comment[], newComment: Comment) => [
...state,
{ ...newComment, pending: true },
]
)
const initialState: CommentState = { success: false, message: '' }
const [state, formAction, isPending] = useActionState(
async (prevState: CommentState, formData: FormData) => {
// Add optimistic comment immediately
addOptimisticComment({
id: crypto.randomUUID(),
text: formData.get('text') as string,
author: 'You',
pending: true,
})
// Then run the actual server action
return addComment(prevState, formData)
},
initialState
)
return (
<div className="space-y-4">
{/* Comment list */}
<ul className="space-y-3">
{optimisticComments.map((comment) => (
<li
key={comment.id}
className={`p-3 rounded-lg border ${
comment.pending ? 'opacity-50 bg-gray-50' : 'bg-white'
}`}
>
<p className="font-medium">{comment.author}</p>
<p className="text-gray-600">{comment.text}</p>
{comment.pending && (
<span className="text-xs text-gray-400">Sending...</span>
)}
</li>
))}
</ul>
{/* Comment form */}
<form action={formAction} className="flex gap-2">
<input
name="text"
placeholder="Add a comment..."
required
className="flex-1 border rounded-lg px-3 py-2"
/>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
Post
</button>
</form>
</div>
)
}The comment appears instantly in the UI with a "pending" visual state, then gets confirmed (or rolled back) when the server responds.
Step 6: Multi-Step Form Pattern
For complex forms like onboarding flows, you can combine Server Actions with client state to build multi-step wizards:
// src/app/onboarding/onboarding-form.tsx
'use client'
import { useState } from 'react'
import { useActionState } from 'react'
import { completeOnboarding, type OnboardingState } from './actions'
const steps = ['Profile', 'Preferences', 'Review'] as const
export default function OnboardingForm() {
const [currentStep, setCurrentStep] = useState(0)
const initialState: OnboardingState = {
success: false,
message: '',
step: 0,
}
const [state, formAction, isPending] = useActionState(
completeOnboarding,
initialState
)
return (
<div className="max-w-lg mx-auto">
{/* Progress bar */}
<div className="flex mb-8">
{steps.map((step, index) => (
<div
key={step}
className={`flex-1 text-center py-2 text-sm font-medium ${
index <= currentStep
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-400 border-b-2 border-gray-200'
}`}
>
{step}
</div>
))}
</div>
<form action={formAction}>
{/* Hidden field to track step */}
<input type="hidden" name="step" value={currentStep} />
{/* Step 1: Profile */}
{currentStep === 0 && (
<div className="space-y-4">
<input name="fullName" placeholder="Full Name" required
className="w-full border rounded-lg px-3 py-2" />
<input name="company" placeholder="Company" required
className="w-full border rounded-lg px-3 py-2" />
</div>
)}
{/* Step 2: Preferences */}
{currentStep === 1 && (
<div className="space-y-4">
<select name="role" className="w-full border rounded-lg px-3 py-2">
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Product Manager</option>
</select>
<select name="experience" className="w-full border rounded-lg px-3 py-2">
<option value="junior">Junior (0-2 years)</option>
<option value="mid">Mid (2-5 years)</option>
<option value="senior">Senior (5+ years)</option>
</select>
</div>
)}
{/* Step 3: Review */}
{currentStep === 2 && (
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-gray-600">
Review your information and click Submit to complete onboarding.
</p>
</div>
)}
{/* Navigation */}
<div className="flex justify-between mt-6">
<button
type="button"
onClick={() => setCurrentStep((s) => Math.max(0, s - 1))}
className={`px-4 py-2 rounded-lg border ${
currentStep === 0 ? 'invisible' : ''
}`}
>
Back
</button>
{currentStep < steps.length - 1 ? (
<button
type="button"
onClick={() => setCurrentStep((s) => s + 1)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg"
>
Next
</button>
) : (
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50"
>
{isPending ? 'Submitting...' : 'Complete Setup'}
</button>
)}
</div>
</form>
</div>
)
}Step 7: File Upload with Server Actions
Server Actions handle file uploads natively through FormData:
// src/app/upload/actions.ts
'use server'
import { writeFile } from 'fs/promises'
import path from 'path'
export type UploadState = {
success: boolean
message: string
url?: string
}
export async function uploadAvatar(
prevState: UploadState,
formData: FormData
): Promise<UploadState> {
const file = formData.get('avatar') as File
if (!file || file.size === 0) {
return { success: false, message: 'Please select a file' }
}
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return { success: false, message: 'Only JPEG, PNG, and WebP images are allowed' }
}
// Validate file size (max 5MB)
const maxSize = 5 * 1024 * 1024
if (file.size > maxSize) {
return { success: false, message: 'File must be smaller than 5MB' }
}
try {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
const uploadPath = path.join(process.cwd(), 'public', 'uploads', filename)
await writeFile(uploadPath, buffer)
return {
success: true,
message: 'Avatar uploaded successfully!',
url: `/uploads/${filename}`,
}
} catch (error) {
return { success: false, message: 'Upload failed. Please try again.' }
}
}// src/app/upload/avatar-form.tsx
'use client'
import { useActionState, useRef } from 'react'
import { uploadAvatar, type UploadState } from './actions'
const initialState: UploadState = { success: false, message: '' }
export default function AvatarUploadForm() {
const [state, formAction, isPending] = useActionState(uploadAvatar, initialState)
const formRef = useRef<HTMLFormElement>(null)
return (
<form ref={formRef} action={formAction} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Profile Photo</label>
<input
name="avatar"
type="file"
accept="image/jpeg,image/png,image/webp"
required
className="block w-full text-sm file:mr-4 file:py-2 file:px-4
file:rounded-lg file:border-0 file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
</div>
{state.message && (
<div className={`px-4 py-3 rounded-lg text-sm ${
state.success
? 'bg-green-50 text-green-800'
: 'bg-red-50 text-red-800'
}`}>
{state.message}
</div>
)}
{state.url && (
<img
src={state.url}
alt="Uploaded avatar"
className="w-24 h-24 rounded-full object-cover"
/>
)}
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-lg disabled:opacity-50"
>
{isPending ? 'Uploading...' : 'Upload Avatar'}
</button>
</form>
)
}Step 8: Reusable Form Helper Pattern
As your app grows, you will want a reusable pattern. Here is a generic helper:
// src/lib/form-utils.ts
import { z } from 'zod'
export type ActionState<T = undefined> = {
success: boolean
message: string
errors?: Record<string, string[]>
data?: T
}
export function createFormAction<TSchema extends z.ZodObject<any>, TResult = void>(
schema: TSchema,
handler: (data: z.infer<TSchema>) => Promise<TResult>
) {
return async (
prevState: ActionState<TResult>,
formData: FormData
): Promise<ActionState<TResult>> => {
const raw = Object.fromEntries(formData.entries())
const result = schema.safeParse(raw)
if (!result.success) {
return {
success: false,
message: 'Validation failed',
errors: result.error.flatten().fieldErrors as Record<string, string[]>,
}
}
try {
const data = await handler(result.data)
return { success: true, message: 'Success', data: data as TResult }
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : 'Something went wrong',
}
}
}
}Now creating a new form action becomes trivial:
// src/app/actions.ts
'use server'
import { createFormAction } from '@/lib/form-utils'
import { createUserSchema } from '@/lib/schemas'
export const createUser = createFormAction(createUserSchema, async (data) => {
// data is fully typed as { name: string, email: string, password: string }
await db.user.create({ data })
return { id: crypto.randomUUID() }
})Testing Your Implementation
Run the development server:
npm run devTest the following scenarios:
- Happy path — Fill all fields correctly and submit. You should see a success message.
- Validation errors — Submit with an invalid email or short password. Field-level errors should appear.
- JavaScript disabled — Disable JS in your browser DevTools and submit the form. It should still work via full page reload.
- Loading state — Click submit and observe the disabled button with "Creating..." text.
- Network errors — Simulate a server failure and verify the error message appears.
Troubleshooting
"Functions cannot be passed directly to Client Components"
This error means you are trying to pass a Server Action as a prop to a Client Component incorrectly. Make sure:
- The Server Action is defined in a
'use server'file - You import it directly in the Client Component, or pass it via a form's
actionprop
"useActionState is not a function"
Ensure you are on React 19+. Check your package.json — if you see React 18.x, upgrade:
npm install react@latest react-dom@latestForm data is empty
Make sure every input has a name attribute. FormData uses the name attribute to collect values — without it, the field is invisible to the server.
Key Takeaways
| Pattern | When to Use |
|---|---|
Plain action={serverAction} | Simple forms in Server Components, no feedback needed |
useActionState | Forms that need loading states, errors, and success messages |
useOptimistic | Instant feedback for actions (comments, likes, toggles) |
createFormAction helper | Reusable pattern for validated actions across your app |
Next Steps
- Explore revalidatePath and revalidateTag to refresh cached data after mutations
- Build a complete CRUD app combining Server Actions with Prisma or Drizzle ORM
- Add rate limiting to your Server Actions for production use
- Combine with next-safe-action library for even more type safety
Conclusion
React 19 Server Actions and useActionState represent a major simplification in how we build forms. By moving validation and mutation logic to the server, you get progressive enhancement for free, eliminate entire categories of client-side bugs, and write significantly less code. The patterns in this tutorial — from basic actions to optimistic updates to reusable helpers — give you a solid foundation for building production-grade forms in any Next.js application.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Zod v4 with Next.js 15: Complete Schema Validation for Forms, APIs, and Server Actions
Master Zod v4 in Next.js 15 — validate forms with Server Actions, secure API routes, parse environment variables, and build end-to-end type-safe apps with the fastest TypeScript schema library.

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.