EmpireUI
Get Pro
← Blog9 min read#react forms#server actions#next.js

React Forms With Server Actions: Validation, Pending State, Optimistic UI

Build React forms with Next.js Server Actions, Zod validation, useFormStatus pending states, and optimistic UI updates — no API routes needed.

Developer writing React form code with server actions on screen

Why Server Actions Change the Form Game

Before Next.js 14, every form submission went through an API route. You'd write a POST /api/contact handler, fetch from the client, deal with CORS if things got messy, and wire up your own loading state. That's a lot of plumbing for something as basic as submitting a name and email.

Server Actions collapse that entire roundtrip. You define an async function on the server, pass it directly as the form's action prop, and Next.js handles the serialization. No endpoint. No fetch. The function runs on the server like it always lived there.

Honestly, the mental model shift is bigger than the code change. You stop thinking in terms of 'client calls server' and start thinking in terms of 'this form triggers this function'. It's closer to how PHP worked in 2006 — but with TypeScript, Zod, and a proper component tree.

Worth noting: Server Actions aren't a React feature, they're a Next.js (and frameworks like Remix-adjacent ones) abstraction. Vanilla React doesn't know what a server action is. Keep that scoped in your head before you try to port this to Vite.

Setting Up a Server Action With Zod Validation

Start with the schema. Zod is the obvious choice here — it runs on the server side, so there's zero bundle-size cost for your client. Define your shape once, use it everywhere.

// app/actions/contact.ts
'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('Enter a valid email'),
  message: z.string().min(10, 'Message too short').max(500),
})

export type ContactState = {
  errors?: {
    name?: string[]
    email?: string[]
    message?: string[]
  }
  message?: string
  success?: boolean
}

export async function submitContact(
  prevState: ContactState,
  formData: FormData
): Promise<ContactState> {
  const raw = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  }

  const result = ContactSchema.safeParse(raw)

  if (!result.success) {
    return {
      errors: result.error.flatten().fieldErrors,
      message: 'Please fix the errors below.',
    }
  }

  // do your DB write, email send, whatever
  await saveToDatabase(result.data)

  return { success: true, message: 'Message sent!' }
}

The 'use server' directive at the top of the file marks every export as a Server Action. The function signature (prevState, formData) is the contract that useActionState expects — you'll see that in the next section.

One more thing — safeParse instead of parse means validation failures don't throw exceptions. You get a typed result object back, which maps cleanly to field-level errors. That's the pattern you want for user-facing forms.

In practice, you'll want to put your server actions in a dedicated app/actions/ directory rather than co-locating them with components. It keeps the 'use server' boundary explicit and avoids accidentally importing server-only modules into client components.

useActionState: Wiring Up the Form Component

React 19 ships useActionState (previously useFormState in the experimental channel). It's the hook that ties your form's current state to your server action, and it handles the pending/result cycle for you.

// app/components/ContactForm.tsx
'use client'

import { useActionState } from 'react'
import { submitContact, type ContactState } from '@/app/actions/contact'

const initialState: ContactState = {}

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, initialState)

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">
          Name
        </label>
        <input
          id="name"
          name="name"
          type="text"
          className="mt-1 block w-full rounded-md border px-3 py-2"
          aria-describedby={state.errors?.name ? 'name-error' : undefined}
        />
        {state.errors?.name && (
          <p id="name-error" className="mt-1 text-sm text-red-500">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          name="email"
          type="email"
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {state.errors?.email && (
          <p className="mt-1 text-sm text-red-500">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">
          Message
        </label>
        <textarea
          id="message"
          name="message"
          rows={4}
          className="mt-1 block w-full rounded-md border px-3 py-2"
        />
        {state.errors?.message && (
          <p className="mt-1 text-sm text-red-500">{state.errors.message[0]}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="rounded-md bg-indigo-600 px-4 py-2 text-white disabled:opacity-50"
      >
        {isPending ? 'Sending...' : 'Send Message'}
      </button>

      {state.message && (
        <p className={state.success ? 'text-green-600' : 'text-red-600'}>
          {state.message}
        </p>
      )}
    </form>
  )
}

The third return value from useActionStateisPending — landed in React 19.0.0. Before that, you'd pull pending from useFormStatus inside a child component. Both still work, but isPending from the hook is cleaner since it lives right where you have access to the action.

Notice the form doesn't use onSubmit at all. The action prop takes your server action directly. This means the form works even with JavaScript disabled — progressively enhanced by default. That's a feature, not an accident.

That said, if you need client-side logic before submission (file previews, conditional fields), you can still intercept with onSubmit, call startTransition, and invoke the action manually. You're not locked into one pattern.

Optimistic UI: Don't Make Users Wait

Pending state is fine for slow operations. But for things like toggling a like button or updating a preference, making users wait 200-400ms for a round-trip feels broken. That's where useOptimistic comes in.

'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions/likes'

type LikeButtonProps = {
  postId: string
  initialLiked: boolean
  initialCount: number
}

export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) {
  const [optimisticState, setOptimistic] = useOptimistic(
    { liked: initialLiked, count: initialCount },
    (state, newLiked: boolean) => ({
      liked: newLiked,
      count: newLiked ? state.count + 1 : state.count - 1,
    })
  )

  const [isPending, startTransition] = useTransition()

  function handleClick() {
    startTransition(async () => {
      setOptimistic(!optimisticState.liked)
      await toggleLike(postId, !optimisticState.liked)
    })
  }

  return (
    <button
      onClick={handleClick}
      className={`flex items-center gap-2 px-3 py-1.5 rounded-full border transition-colors ${
        optimisticState.liked
          ? 'bg-red-50 border-red-200 text-red-600'
          : 'border-gray-200 text-gray-500 hover:border-gray-300'
      }`}
    >
      <span>{optimisticState.liked ? '❤️' : '🤍'}</span>
      <span className="text-sm">{optimisticState.count}</span>
    </button>
  )
}

The second argument to useOptimistic is your reducer — it takes the current state and whatever you passed to setOptimistic, and returns the new optimistic state. If the server action fails, React automatically reverts to the last confirmed state. You get rollback for free.

Look, this pattern has a subtlety: setOptimistic only works inside startTransition. That's why you need useTransition alongside it. The transition marks the update as non-urgent, which gives React permission to interrupt it if something more important comes along.

Quick aside: for list-level optimistic updates (adding an item to a list before the server confirms), you'd lift the useOptimistic hook to the parent component that renders the list. The pattern scales — just keep the state shape flat and the reducer pure.

Handling Errors Gracefully and Resetting Form State

One thing people miss: after a successful submission, your form fields still have the old values in them. The form isn't reset automatically. You need to handle that explicitly.

import { useActionState, useEffect, useRef } from 'react'

export function ContactForm() {
  const formRef = useRef<HTMLFormElement>(null)
  const [state, formAction, isPending] = useActionState(submitContact, {})

  useEffect(() => {
    if (state.success) {
      formRef.current?.reset()
    }
  }, [state.success])

  return (
    <form ref={formRef} action={formAction}>
      {/* ... fields */}
    </form>
  )
}

For network-level errors — the server action throws, the request times out, whatever — Next.js will catch the error and you can handle it with an error.tsx boundary. But for expected validation failures, keep those in the action's return value, not as thrown exceptions. Throwing from a Server Action surfaces as an unhandled error to the boundary, which is the wrong UX for 'your email is invalid'.

That's a real distinction worth internalizing: use return for user errors, throw for programmer errors or truly unexpected failures.

If you're building something more polished — say, a contact form with a glassmorphism card treatment — check out the glassmorphism components available in Empire UI. Pair them with this validation pattern and you get a form that both looks good and handles errors properly at about 2px blur radius per layer of glass.

Progressive Enhancement and Accessibility

Server Actions give you progressive enhancement almost for free. The form works without JavaScript because the browser can POST to the action URL natively. When JS loads, React hydrates and takes over — same form, richer behavior. That 0-JS baseline matters more than people realize for slow connections.

On the accessibility side, make sure you're wiring aria-describedby to point at your error messages (you saw that in the code above). Screen readers need to know the error is associated with the field, not just nearby in the DOM. Set aria-invalid="true" on fields with errors too.

<input
  id="email"
  name="email"
  type="email"
  aria-invalid={!!state.errors?.email}
  aria-describedby={state.errors?.email ? 'email-error' : undefined}
/>
{state.errors?.email && (
  <p id="email-error" role="alert" className="text-sm text-red-500">
    {state.errors.email[0]}
  </p>
)}

The role="alert" on the error paragraph means screen readers announce the error immediately when it appears — no focus movement needed. Combine that with a visible focus ring (don't strip outline in your CSS without replacing it) and you're covering the basics.

Worth noting: if you need more control over validation timing — like validating on blur rather than submit — you'll want to pair Server Actions with something like react-hook-form. See our piece on react-form-react-hook-form for that approach. Server Actions and RHF aren't mutually exclusive; RHF can call a server action as its submit handler.

Putting It Together: A Real-World Pattern

Here's the full mental model collapsed into one pattern you can drop into any project: schema in actions/, useActionState in the form component, optimistic hook for instant feedback on toggle-style interactions, and useEffect to reset after success.

// The three-layer stack:
// 1. Zod schema (server, in actions/)
// 2. Server Action (server, in actions/)
// 3. Form component (client, 'use client')

// Layer 1 + 2
// app/actions/newsletter.ts
'use server'
import { z } from 'zod'

const Schema = z.object({ email: z.string().email() })

export async function subscribe(prev: { error?: string; success?: boolean }, fd: FormData) {
  const result = Schema.safeParse({ email: fd.get('email') })
  if (!result.success) return { error: result.error.errors[0].message }
  await addToList(result.data.email) // your email provider call
  return { success: true }
}

// Layer 3
// app/components/NewsletterForm.tsx
'use client'
import { useActionState } from 'react'
import { subscribe } from '@/app/actions/newsletter'

export function NewsletterForm() {
  const [state, action, pending] = useActionState(subscribe, {})
  if (state.success) return <p>You're in. Check your inbox.</p>
  return (
    <form action={action} className="flex gap-2">
      <input name="email" type="email" placeholder="you@example.com"
        className="flex-1 rounded border px-3 py-2" />
      <button type="submit" disabled={pending}
        className="rounded bg-black px-4 py-2 text-white disabled:opacity-40">
        {pending ? '...' : 'Subscribe'}
      </button>
      {state.error && <p className="text-red-500 text-sm">{state.error}</p>}
    </form>
  )
}

That's it. No API route, no fetch, no separate loading state variable. The whole thing is ~30 lines of actual code once you strip the types.

For teams shipping design-forward products, this pattern pairs well with styled form components. The box shadow generator is useful for getting your input focus rings exactly right — those subtle 0 0 0 3px rgba shadows that make forms feel premium without 10 lines of custom CSS.

And if you're building a full Next.js project from scratch, our templates include pre-wired form patterns with Server Actions so you're not starting from zero every time.

FAQ

Do Server Actions replace API routes entirely?

For form submissions and mutations triggered from your own UI, yes. For webhooks, third-party callbacks, or endpoints consumed by other services, you still need API routes — Server Actions aren't publicly addressable URLs.

Can I use Server Actions with react-hook-form?

Yes. Pass your server action to RHF's handleSubmit callback, or use useActionState alongside RHF for client-side validation before the server call. They compose fine.

What happens if a Server Action throws an error?

Next.js catches it and triggers the nearest error.tsx boundary. For expected user-facing errors like validation failures, return an error object instead of throwing — that keeps the form interactive.

Is useOptimistic stable in React 19?

Yes, useOptimistic is stable in React 19.0.0 (released early 2025). It was experimental in the canary builds before that, so check your React version before using it.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js Server Actions in 2026: Forms, Mutations and the Right PatternsNext.js Server Actions Advanced: Optimistic UI, File Upload, Rate LimitZod vs Yup vs Valibot in 2026: Schema Validation ShowdownZod Guide 2026: Schemas, Transforms, Refinements and Error Formatting