Server Actions
Validate on the client, then submit to a Server Action with useActionState.
React 19 Server Actions and useActionState pair well with React Hook Form. React Hook Form validates on the client and gives instant feedback. The action validates again on the server, saves, and returns a result. useActionState tracks the pending state and the result, so you don't need to sync them with useEffect.
The flow
handleSubmitruns client validation. If it fails, the action never runs.- On success, call the dispatch function from
useActionStateinsidestartTransition, so React tracksisPending. - The action parses the input again, saves, and returns a typed result.
- Field errors in the result become form errors through
useForm'serrorsoption.
Example
This example works with the Next.js App Router or any React 19 framework that supports Server Actions.
schema.ts: shared by the client and the action, with the result type.
import { z } from "zod"export const profileSchema = z.object({name: z.string().trim().min(1, "Enter your name."),username: z.string().trim().regex(/^[a-z0-9_]{3,20}$/, "Use 3–20 lowercase letters, numbers, or _."),})export type ProfileValues = z.infer<typeof profileSchema>export type ProfileField = keyof ProfileValuesexport type ActionState =| { status: "idle" }| { status: "success"; savedAt: string }| {status: "error"message?: stringfieldErrors?: Partial<Record<ProfileField, string>>}
actions.ts: the Server Action. It parses its input with the same schema, because anyone can call a Server Action with any payload.
"use server"import { profileSchema, type ActionState, type ProfileField } from "./schema"// Replace with your application's authentication and data modules.import { requireUser } from "./auth"import { isUsernameTaken, updateUser } from "./db"export async function updateProfile(_previous: ActionState,input: unknown): Promise<ActionState> {// The input is untrusted, even though the client validated it.const result = profileSchema.safeParse(input)if (!result.success) {const fieldErrors: Partial<Record<ProfileField, string>> = {}for (const issue of result.error.issues) {const name = issue.path[0] as ProfileFieldfieldErrors[name] ??= issue.message}return { status: "error", fieldErrors }}const user = await requireUser()if (await isUsernameTaken(result.data.username, user.id)) {return {status: "error",fieldErrors: { username: "That username is taken." },}}try {await updateUser(user.id, result.data)return { status: "success", savedAt: new Date().toISOString() }} catch {return { status: "error", message: "We couldn't save your profile." }}}
profile-form.tsx: a Client Component.
"use client"import { startTransition, useActionState, useMemo } from "react"import { useForm, type FieldErrors } from "react-hook-form"import { zodResolver } from "@hookform/resolvers/zod"import { updateProfile } from "./actions"import { profileSchema, type ActionState, type ProfileValues } from "./schema"const initialState: ActionState = { status: "idle" }export function ProfileForm({ profile }: { profile: ProfileValues }) {const [state, submitAction, isPending] = useActionState(updateProfile,initialState)// Turn the action's field errors into form errors. useMemo keeps the// object stable between renders, as the errors option requires.const serverErrors = useMemo(() => {if (state.status !== "error" || !state.fieldErrors) return undefinedconst errors: FieldErrors<ProfileValues> = {}for (const [name, message] of Object.entries(state.fieldErrors)) {errors[name as keyof ProfileValues] = { type: "server", message }}return errors}, [state])const {register,handleSubmit,formState: { errors },} = useForm<ProfileValues>({resolver: zodResolver(profileSchema),defaultValues: profile,errors: serverErrors,})return (<formnoValidateonSubmit={handleSubmit((values) => {startTransition(() => submitAction(values))})}><label htmlFor="name">Name</label><input id="name" aria-invalid={!!errors.name} {...register("name")} />{errors.name && <p role="alert">{errors.name.message}</p>}<label htmlFor="username">Username</label><inputid="username"aria-invalid={!!errors.username}{...register("username")}/>{errors.username && <p role="alert">{errors.username.message}</p>}{state.status === "error" && state.message && (<p role="alert">{state.message}</p>)}{state.status === "success" && <p role="status">Profile saved.</p>}<button type="submit" disabled={isPending}>{isPending ? "Saving…" : "Save"}</button></form>)}
How it fits together:
isPendingistruefrom dispatch until the action returns, so the button can't be pressed twice.- The action receives a plain object, not
FormData, becausehandleSubmitpasses the validated values. serverErrorsis rebuilt only whenstatechanges. Theerrorsoption needs a stable object, because a new object on every render would update the form repeatedly.- A server error on
usernameclears as soon as the user edits that field, like any other error. state.messagecovers failures that don't belong to a field.
Without JavaScript
This pattern needs client JavaScript, because handleSubmit runs in the browser. If the form must work before hydration, use a native form action: pass the action to <form action={...}> and read FormData on the server. React Hook Form's Form component can also submit to an action through its action prop.
Things to watch
- Authorize in the action. A Server Action is a public endpoint. Check the user's session and permissions inside it, as
requireUserdoes here. - Return data, don't throw. Return expected failures, such as validation errors, as values. A thrown error reaches the nearest error boundary instead of the form.
- Reset after saving. To make the saved values the new baseline, call
resetwhenstate.statusbecomes"success", for example in an effect keyed onstate. - Next.js setup. See the Next.js guide for Client and Server Component boundaries, and React 19 for form actions.