Forms and Actions

React 19 adds first-class support for forms: server actions, pending states, optimistic updates, and progressive enhancement. Forms work without JavaScript, then enhance when it loads.

Server Actions

Server actions are async functions that run on the server. Mark them with "use server" and pass them directly to a form’s action prop.

// app/actions.ts
"use server";

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await db.posts.create({
    data: { title, content, publishedAt: new Date() },
  });

  revalidatePath("/posts");
}
// app/posts/new/page.tsx
import { createPost } from "@/app/actions";

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Write your post..." required />
      <button type="submit">Publish</button>
    </form>
  );
}

This form works even before JavaScript loads. The browser submits it as a standard form POST, the server action runs, and the page updates.

Tip: Server actions replace API routes for mutations. Instead of POST /api/posts with fetch, you call a function. Type safety, no serialization boilerplate, automatic revalidation.

useActionState

useActionState tracks the pending state and return value of an action. It replaces the older useFormState.

"use client";

import { useActionState } from "react";
import { createPost } from "@/app/actions";

// Updated action that returns state
async function createPostAction(
  prevState: { error?: string; success?: boolean },
  formData: FormData
) {
  "use server";

  const title = formData.get("title") as string;
  if (title.length < 3) {
    return { error: "Title must be at least 3 characters" };
  }

  try {
    await db.posts.create({ data: { title, content: formData.get("content") as string } });
    revalidatePath("/posts");
    return { success: true };
  } catch {
    return { error: "Failed to create post" };
  }
}

export function NewPostForm() {
  const [state, action, isPending] = useActionState(createPostAction, {});

  return (
    <form action={action}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />

      {state.error && (
        <p className="text-red-500">{state.error}</p>
      )}
      {state.success && (
        <p className="text-green-500">Post created!</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish"}
      </button>
    </form>
  );
}

What useActionState Gives You

ValueTypeDescription
stateYour return typeThe value returned by the last action call
actionFunctionA wrapped version of your action to pass to <form action>
isPendingbooleantrue while the action is running

Optimistic Updates with useOptimistic

Show the result immediately, before the server responds. If the server fails, React rolls back automatically.

"use client";

import { useOptimistic } from "react";
import { toggleTodo } from "@/app/actions";

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, setOptimisticTodo] = useOptimistic(
    todos,
    (currentTodos: Todo[], toggledId: string) =>
      currentTodos.map((t) =>
        t.id === toggledId ? { ...t, completed: !t.completed } : t
      )
  );

  async function handleToggle(id: string) {
    setOptimisticTodo(id);  // instantly update UI
    await toggleTodo(id);    // server action runs in background
  }

  return (
    <ul>
      {optimisticTodos.map((todo) => (
        <li key={todo.id}>
          <form action={() => handleToggle(todo.id)}>
            <button type="submit">
              {todo.completed ? "✓" : "○"} {todo.title}
            </button>
          </form>
        </li>
      ))}
    </ul>
  );
}

How it works:

  1. User clicks a todo
  2. setOptimisticTodo immediately shows the toggled state
  3. toggleTodo server action runs
  4. If it succeeds, the parent re-renders with the new server data (replacing the optimistic value)
  5. If it fails, React reverts to the pre-optimistic state

Gotcha: useOptimistic resets to the actual value whenever the parent re-renders with new props. This means you need to keep todos in sync with the server (via revalidatePath or similar).

Form Validation Patterns

Client-Side Validation

Use HTML validation attributes plus custom validation in the action:

"use client";

import { useActionState } from "react";

interface FormState {
  errors: Record<string, string>;
  success?: boolean;
}

async function submitForm(prev: FormState, formData: FormData): Promise<FormState> {
  const errors: Record<string, string> = {};

  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  if (!email.includes("@")) errors.email = "Invalid email";
  if (password.length < 8) errors.password = "Must be at least 8 characters";

  if (Object.keys(errors).length > 0) return { errors };

  // Server-side validation and submission
  const res = await fetch("/api/register", {
    method: "POST",
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) return { errors: { form: "Registration failed" } };
  return { errors: {}, success: true };
}

function RegisterForm() {
  const [state, action, isPending] = useActionState(submitForm, { errors: {} });

  return (
    <form action={action} className="space-y-4">
      <div>
        <input
          name="email"
          type="email"
          required
          placeholder="Email"
          className={state.errors.email ? "border-red-500" : ""}
        />
        {state.errors.email && (
          <p className="text-red-500 text-sm mt-1">{state.errors.email}</p>
        )}
      </div>

      <div>
        <input
          name="password"
          type="password"
          required
          minLength={8}
          placeholder="Password"
          className={state.errors.password ? "border-red-500" : ""}
        />
        {state.errors.password && (
          <p className="text-red-500 text-sm mt-1">{state.errors.password}</p>
        )}
      </div>

      {state.errors.form && (
        <p className="text-red-500">{state.errors.form}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "Creating account..." : "Register"}
      </button>
    </form>
  );
}

With a Validation Library (Zod)

import { z } from "zod";

const schema = z.object({
  title: z.string().min(1, "Title required").max(100, "Too long"),
  content: z.string().min(10, "Content must be at least 10 characters"),
  category: z.enum(["blog", "tutorial", "news"]),
});

async function createPost(prev: FormState, formData: FormData): Promise<FormState> {
  const raw = Object.fromEntries(formData);
  const result = schema.safeParse(raw);

  if (!result.success) {
    const errors: Record<string, string> = {};
    result.error.issues.forEach((issue) => {
      errors[issue.path[0] as string] = issue.message;
    });
    return { errors };
  }

  await db.posts.create({ data: result.data });
  revalidatePath("/posts");
  return { errors: {}, success: true };
}

Before / After Comparison

Before React 19

function OldForm() {
  const [title, setTitle] = useState("");
  const [error, setError] = useState("");
  const [isPending, setIsPending] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setIsPending(true);
    setError("");

    try {
      const res = await fetch("/api/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title }),
      });

      if (!res.ok) throw new Error("Failed");
      setTitle("");
    } catch (err) {
      setError("Something went wrong");
    } finally {
      setIsPending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      {error && <p className="text-red-500">{error}</p>}
      <button disabled={isPending}>
        {isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

After React 19

function NewForm() {
  const [state, action, isPending] = useActionState(createPost, { error: null });

  return (
    <form action={action}>
      <input name="title" />
      {state.error && <p className="text-red-500">{state.error}</p>}
      <button disabled={isPending}>
        {isPending ? "Saving..." : "Save"}
      </button>
    </form>
  );
}

Key differences:

  • No useState for form fields (use FormData)
  • No e.preventDefault() (React handles it)
  • No manual isPending toggle (useActionState provides it)
  • No fetch boilerplate (server action handles the API call)
  • Works without JavaScript (progressive enhancement)
  • Automatic serialization/deserialization

Tip: You don’t have to go all-in on server actions. Client-side forms with action={clientFunction} that receives FormData also work. You get the FormData API and progressive enhancement without needing a server.

Next: Gotchas for common mistakes and React 19 breaking changes