Server Actions and useActionState

Server Actions are the mutation counterpart to Server Components. A function marked with 'use server' runs on the server when invoked from the client—typically through a form action, formAction, or a transition—without you hand-rolling a JSON API for every form. useActionState (React 19) wires that server function into component state: the previous result, the pending flag, and a stable action reference for declarative forms.

For TypeScript developers, the payoff is end-to-end typing of the “round trip”: define a FormState shape, return it from the action, and narrow errors in the UI without guessing field names from loose objects.

Compared with a hand-written POST /api/posts route, the Server Action path keeps validation, persistence, and cache rules in one place. The client does not need to know status codes, Content-Type headers, or JSON parsing edge cases—it forwards FormData and interprets the typed state object. That reduction in glue code matters most in large teams, where every endpoint pair historically drifted into slightly different error shapes.

Anatomy of a server action

Below, createPost validates FormData with Zod, authenticates with auth(), persists with Prisma-style db, and then revalidates cached data so the next navigation sees fresh lists. The action accepts prevState plus formData—the signature useActionState expects when you need to accumulate errors or success flags across submissions.

// actions/posts.ts
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { auth } from "@/lib/auth";

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(1),
  published: z.boolean().default(false),
});

type FormState = {
  errors?: { title?: string[]; content?: string[] };
  success?: boolean;
};

export async function createPost(prevState: FormState, formData: FormData): Promise<FormState> {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  const result = CreatePostSchema.safeParse({
    title: formData.get("title"),
    content: formData.get("content"),
    published: formData.get("published") === "true",
  });

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  await db.post.create({
    data: { ...result.data, authorId: session.user.id },
  });

  revalidateTag("posts");
  return { success: true };
}

Why validate on the server if you also validate on the client? Never trust the client. Client validation improves UX; Zod (or similar) on the server is your security and integrity boundary. The FormState type is the contract your UI consumes.

Wiring the client with useActionState

The client component stays thin: it binds formAction, reads state for field errors, and disables the submit button while isPending is true. You avoid manual useState for “submitting” unless you need extra local UI state.

// Client Component using the action
'use client'
import { useActionState } from 'react'
import { createPost } from '@/actions/posts'

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, {})

  return (
    <form action={formAction}>
      <input name="title" required />
      {state.errors?.title && <p>{state.errors.title[0]}</p>}
      <textarea name="content" required />
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

TypeScript note: ensure createPost’s first argument type matches what useActionState infers; if inference drifts, pass explicit generics or wrap the action in a thin adapter. Keeping FormState in a shared module (imported type-only on the client if you prefer) avoids duplication.

useFormStatus for child controls

useActionState answers “what did the server last say, and is a submission in flight?” useFormStatus answers “is my parent form pending?” from a descendant component—ideal for disabling ancillary buttons, showing a spinner on a row of actions, or dimming a file input while the outer form is submitting. Compose them: keep useActionState on the form shell, extract a tiny SubmitLabel child that calls useFormStatus so you do not thread isPending through every stylistic variant.

Authorization, idempotency, and errors

Throwing new Error('Unauthorized') inside an action produces an error boundary outcome for the client caller in many setups—fine for hard failures, harsh for forms. Prefer returning a FormState union with an error: 'unauthorized' | 'rate_limited' field when you want inline recovery instead of a full-page boundary. The same discipline applies to duplicate submissions: disable the button with isPending, and on the server consider idempotency keys or natural uniqueness constraints so double-clicks do not create twin rows.

Revalidation as part of the mutation story

After a successful write, the database is updated—but cached UI might still show stale data. revalidateTag and revalidatePath connect Server Actions to Next’s cache layers: tags map to fetch / unstable_cache entries you declared earlier, and paths target route segments for invalidation.

You can combine strategies: tag for list pages, path for the detail page you edited. The right granularity depends on how aggressively you cache reads.

Mental model: actions are not “just fetch”

Server Actions are server entry points with framework integration: CSRF considerations, payload limits, and serialization rules still apply in production. Treat them like commands in CQRS terms: validate, authorize, mutate, revalidate, return a small state delta. Avoid returning large entities when a boolean and a few errors suffice.

They also inherit your server runtime constraints: cold starts, maximum duration, and database transaction timeouts. Long-running work belongs in a job queue or background worker invoked from a thin action that only enqueues an ID. The form should acknowledge receipt quickly; the UI can poll or subscribe for completion. That split keeps perceived latency low without blocking the action’s thread of execution longer than your platform allows.

Progressive enhancement and pending UI

Because the default wiring is a real HTML form action, you preserve progressive enhancement where supported: users without your client bundle can still post (with full page navigation), while hydrated users get isPending and inline errors. That dual story is harder to replicate when every mutation is a bespoke fetch in useEffect.

Together, Server Actions and useActionState let you express mutations as typed, server-owned workflows with client ergonomics that feel familiar—forms, pending states, and validation errors—without scattering endpoints across your codebase.

For TypeScript-heavy teams, the maintenance win is centralized command validation: Zod schemas colocated with persistence code, FormState as the single discriminated output, and UI components that pattern-match on state.success, state.errors, and future fields like state.message without inventing parallel DTOs on the client. That alignment is harder to achieve when every mutation is a fetch returning anonymous JSON.