Form Validation and Pending UI
Make Server Action forms production-ready: validate the input, send back clear error messages, and show a "Saving…" state while it runs.
What you will learn
- Validate form data on the server and return errors
- Show field errors with useActionState
- Disable the button while submitting with useFormStatus
A bare Server Action form is not enough
You can wire a form to a Server Action, but a real form needs three more things to be usable: it must reject bad input (an empty title, a too-short comment), tell the user what went wrong, and show that something is happening while it saves so they do not click twice. This lesson adds all three.
Step 1 — validate on the server
Never trust what a form sends — a user can leave fields blank or send junk. So the Server Action checks the data first and, if it is bad, returns an errors object instead of saving. (Many teams use a library called Zod to describe the rules; here we validate by hand so the idea is crystal clear.)
// app/actions.js
'use server';
export async function createComment(prevState, formData) {
const text = formData.get('text');
// validate
if (!text || text.trim().length < 3) {
return { error: 'Comment must be at least 3 characters.' };
}
// ...save the comment to the database...
return { success: true };
}Note: Output (return values, not screen text): - Empty or too-short text -> { error: "Comment must be at least 3 characters." } - Valid text -> { success: true } The action never saves bad data. It hands a result back to the page so the page can show the message.
Step 2 — show errors with useActionState
How does that returned { error } get onto the screen? React gives a hook called useActionState. You hand it your action; it gives you back the latest state (whatever the action returned last time) and a wrapped action to put on the form. When the action returns { error: ... }, that becomes the new state and you display it.
Note the action above takes prevState as its first argument — useActionState passes the previous returned state there, and the form data second.
'use client';
import { useActionState } from 'react';
import { createComment } from './actions';
export default function CommentForm() {
const [state, formAction] = useActionState(createComment, null);
return (
<form action={formAction}>
<input name="text" placeholder="Your comment" />
<button type="submit">Post</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
</form>
);
}Note: Output: - Submit a 1-letter comment -> red text appears: "Comment must be at least 3 characters." - Submit a real comment -> no error; the action saved it. The error came from the server, travelled back as state, and rendered under the input — no page reload.
Step 3 — a pending "Saving…" state with useFormStatus
While the action runs, the button should say "Saving…" and be disabled so the user cannot submit twice. React gives useFormStatus, which reports a pending flag that is true while the form is submitting. It must be read from a component inside the form, so we make a small SubmitButton.
'use client';
import { useFormStatus } from 'react-dom';
export default function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving…' : 'Post'}
</button>
);
}Note: Output (behaviour): While the Server Action is running, the button reads "Saving…" and is greyed out (disabled). When the action finishes, it returns to "Post". The user gets clear feedback and cannot double-submit.
How the three pieces work together, step by step
Here is the full journey when a user submits the comment form:
- The user types a comment and clicks Post. Because the form uses
action={formAction}, the data is sent to the server. - Instantly,
useFormStatusflipspendingtotrue, so the button shows "Saving…" and is disabled. - On the server,
createCommentruns and validates the text first. - If the text is too short, the action returns
{ error: ... }.useActionStatestores that asstate, the red message appears, and nothing was saved. - If the text is valid, the action saves it and returns
{ success: true }. (You would alsorevalidatePathhere so the new comment appears.) - Either way, the action finishes,
pendinggoes back tofalse, and the button returns to "Post".
| Tool | Job | Where it lives |
|---|---|---|
| Server-side check | Reject bad input, return an error | Inside the Server Action |
useActionState | Hold and show the returned error | The form (Client) Component |
useFormStatus | Show the "Saving…" pending state | A child component inside the form |
Progressive enhancement — a bonus
Progressive enhancement means the form still works even if JavaScript has not loaded yet. Because a Server Action form posts to the server the normal HTML way, a user on a slow connection can submit before the page is fully interactive. The pending and error niceties are layered on top once JavaScript loads — but the core form never breaks.
Tip: In real projects, replace the hand-written check with Zod: describe the rules once (e.g. z.string().min(3)) and let it produce the error messages. The wiring with useActionState and useFormStatus stays exactly the same.
Watch out: Always validate on the server, inside the action — never rely only on the browser. A user can bypass browser checks, so the server is the only place that truly protects your database.
Q. Which hook shows a "Saving…" pending state while a Server Action form is submitting?
✍️ Practice
- Make a Server Action that returns
{ error }when a name field is empty, and show it withuseActionState. - Add a
SubmitButtonusinguseFormStatusthat reads "Saving…" while the action runs.
🏠 Homework
- Build a contact form whose Server Action validates name and message, returns field errors via useActionState, and shows a disabled "Sending…" button via useFormStatus.