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.

Build a Full-Stack CRUD App with MongoDB, Mongoose, and Next.js 15
Learn how to build a production-ready full-stack application with MongoDB Atlas, Mongoose ODM, and Next.js 15 App Router. This tutorial covers schema design, Server Actions, CRUD operations, validation, and deployment.