Form validation and errors
Form data cannot be saved as is: a visitor may leave a field empty or type an address with spaces. The check (validation) happens in the server action, and the errors go back to the form. The useActionState hook from React 19 connects the action and the form.
The action returns errors
Rewrite app/posts/actions.ts. The action now takes the previous state as its first argument and returns a new one:
// app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { addPost } from '@/lib/posts';
export type FormState = {
errors: { title?: string; slug?: string; body?: string };
};
export async function createPost(prevState: FormState, formData: FormData): Promise<FormState> {
const title = String(formData.get('title') ?? '').trim();
const slug = String(formData.get('slug') ?? '').trim();
const body = String(formData.get('body') ?? '').trim();
const errors: FormState['errors'] = {};
if (title.length < 3) errors.title = 'The title needs at least 3 characters';
if (!/^[a-z0-9-]+$/.test(slug)) errors.slug = 'Only Latin letters, digits and hyphens';
if (body === '') errors.body = 'Write the post text';
if (Object.keys(errors).length > 0) {
return { errors };
}
await addPost({ slug, title, body });
revalidatePath('/posts');
redirect(`/posts/${slug}`);
}If there are errors, the action returns them as an object and saves nothing. If there are none, it saves the post and redirects as before.
The form with useActionState
The hook only works in a client component, so let us move the form into components/NewPostForm.tsx:
// components/NewPostForm.tsx
'use client';
import { useActionState } from 'react';
import { createPost, type FormState } from '@/app/posts/actions';
const initialState: FormState = { errors: {} };
export default function NewPostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" placeholder="Title" />
{state.errors.title && <p>{state.errors.title}</p>}
<input name="slug" placeholder="Address" />
{state.errors.slug && <p>{state.errors.slug}</p>}
<textarea name="body" placeholder="Post text" />
{state.errors.body && <p>{state.errors.body}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Publish'}
</button>
</form>
);
}useActionState returns three values: the current state (initialState at first, then the action's result), a function for the action attribute and an isPending flag that is true while the action runs.
Using it on the page
// app/posts/new/page.tsx
import NewPostForm from '@/components/NewPostForm';
export default function NewPostPage() {
return (
<main>
<h1>New post</h1>
<NewPostForm />
</main>
);
}Open /posts/new and submit the empty form — three messages appear under the fields, and the button is disabled during the request.
Validation libraries
When there are many fields, the rules are described as a schema in a library such as Zod instead of a chain of if statements. The principle stays the same: check on the server, errors in the state.
Common mistake
Being surprised that the fields were cleared after an error. In React 19 a form with an action is reset after every submission. To keep the typed text, return the values in the state together with the errors and put them into the fields with defaultValue, for example defaultValue={state.values.title}.