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/postswithfetch, 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
| Value | Type | Description |
|---|---|---|
state | Your return type | The value returned by the last action call |
action | Function | A wrapped version of your action to pass to <form action> |
isPending | boolean | true 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:
- User clicks a todo
setOptimisticTodoimmediately shows the toggled statetoggleTodoserver action runs- If it succeeds, the parent re-renders with the new server data (replacing the optimistic value)
- If it fails, React reverts to the pre-optimistic state
Gotcha:
useOptimisticresets to the actual value whenever the parent re-renders with new props. This means you need to keeptodosin sync with the server (viarevalidatePathor 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
useStatefor form fields (useFormData) - No
e.preventDefault()(React handles it) - No manual
isPendingtoggle (useActionStateprovides it) - No
fetchboilerplate (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 receivesFormDataalso work. You get theFormDataAPI and progressive enhancement without needing a server.
Next: Gotchas for common mistakes and React 19 breaking changes