Forms with Server Actions
Showing pending state and validation errors for a Server Action using React's useActionState hook.
阅读需 2 分钟
A Server Action that just runs and redirects is only half a form. Real forms need to show a loading state while submitting and display validation errors without a full page reload — and both of those require a small Client Component wrapper, even though the actual mutation still runs on the server.
Returning errors instead of throwing them
For validation failures — a missing title, an email that's already taken — avoid throw. Model the failure as a normal return value instead, since these are expected outcomes of calling the action, not bugs:
// app/actions.ts
"use server";
export async function createPost(prevState: unknown, formData: FormData) {
const title = formData.get("title");
if (!title || String(title).trim().length === 0) {
return { message: "Title is required." };
}
await db.insert(posts).values({ title: String(title) });
return { message: null };
}Notice the extra prevState first argument — that's required by the hook you'll use to call this action from a form, covered next.
useActionState: pending state and returned errors together
useActionState is a React hook built exactly for this pairing: it gives you the action's latest returned state, a wrapped version of the action to pass to your form, and a pending boolean for free.
// app/ui/new-post-form.tsx
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
const initialState = { message: null };
export default function NewPostForm() {
const [state, formAction, pending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<label htmlFor="title">Title</label>
<input type="text" id="title" name="title" required />
{state?.message && <p aria-live="polite">{state.message}</p>}
<button disabled={pending}>{pending ? "Publishing..." : "Publish"}</button>
</form>
);
}The aria-live="polite" attribute matters as much as the visible text — it tells screen readers to announce the error message when it appears, since nothing else on the page signals that a validation failure just happened.
Why this needs to be a Client Component
useActionState is a React hook, and hooks only run in Client Components — so the form itself has to cross the "use client" boundary even though createPost runs entirely on the server. This is a good example of the composition pattern from earlier lessons: the action stays a server-only function, imported into a small client wrapper whose only job is rendering the form and reflecting its state.
Optimistic-feeling UX without extra libraries
For simple cases, pending alone is often enough to make a form feel responsive — disable the submit button and swap its label, and most users perceive that as immediate feedback. If you need the new item to visually appear before the server confirms it (adding an item to a list, say), React's useOptimistic hook pairs naturally with Server Actions for that, updating the UI immediately and reconciling once the action resolves — worth reaching for once basic pending states feel too slow for a given interaction, but unnecessary complexity for most forms.