Server Action form state hydration breaks when the server renders a form with
the result of a submitted action (useActionState with a permalink,
submitted before JavaScript loaded) but the client hydrates without that
state. Fields and messages jump back to their initial values. Let the framework
pass the form state along, and render the form from the action state only.
Symptoms
React says nothing. Form control values are properties, not text, and React 18 and 19 report no warning when a control's value or selected option changes during hydration, not even in development. The user sees the message they submitted disappear, or a field reset, right after the page becomes interactive.
hydration-proof reports it as HP1012, a warning:
HP1012 Form state differs between server and client (server action form state, 90%)
#newsletter-email
attribute: value
server: "ada@example.com"
client: ""
→ The server rendered this form with the result of a submitted Server Action (useActionState with a permalink), but the client hydrated without that state. Let the framework pass the form state to hydrateRoot (Next.js does this), and render the form from the action state only.It picks the cause Server Action form state when the difference sits inside
a form the server rendered with action state, which React marks in the server
HTML with a <!--F!--> comment before the <form>. The same code without that
marker is usually a value computed differently on each side: see
browser APIs in render.
Why Server Action form state hydration breaks
useActionState supports progressive enhancement. If a visitor submits the
form before the JavaScript bundle loads, the browser posts it to the server,
the server runs the action and renders the page with the result. From the
React documentation of useActionState: "If reducerAction is a Server
Function and the form is submitted before the JavaScript bundle loads, the
browser will navigate to the specified permalink URL rather than the current
page's URL."
For hydration to match, the client must start from the same result. React
takes it through the formState option of hydrateRoot, which "must be the
same value as the formState passed to the server renderer". When it is
missing, useActionState returns initialState in the browser while the server
HTML shows the submitted state.
That happens when:
- a custom server or framework integration renders with form state but does not
pass it to
hydrateRoot; - the permalink page renders a different form component, action or permalink than the page the form was on, so React cannot match the state to the form;
- the form copies the action result into local state or computes default values from something else, so the server and client disagree even with the right state.
How to fix it
Let the framework pass the form state
Next.js passes the form state for you. With a custom server, pass the value the
server rendered with to hydrateRoot, serialized into the page:
import { hydrateRoot } from "react-dom/client";
// The same value the server passed to its renderer as `formState`,
// serialized into the page by your server.
const formState = JSON.parse(
document.getElementById("form-state")?.textContent ?? "null"
);
hydrateRoot(document, <App />, { formState });Render the form from the action state only
Derive every value the form shows from the state useActionState returns, and
render the same form, action and permalink on the permalink page:
"use client";
import { useActionState } from "react";
import { subscribe } from "./actions";
export function Signup() {
// Before: const [email] = useState(() => readDraftFromStorage());
const [state, formAction, pending] = useActionState(
subscribe,
{ email: "", message: "" },
"/newsletter"
);
return (
<form action={formAction}>
<input id="newsletter-email" name="email" defaultValue={state.email} />
<button disabled={pending}>Subscribe</button>
{state.message && <p>{state.message}</p>}
</form>
);
}React's documentation adds a caveat for the permalink: "ensure the same form
component is rendered on the destination page (including the same
reducerAction and permalink) so React knows how to pass the state through."
Keep default values identical on both sides
Outside Server Actions, HP1012 means a control's defaultValue,
defaultChecked or selected option was computed differently on each side. A
<textarea> or <select> whose default depends on storage or a browser API
changes silently during hydration. Compute defaults from data the server has,
and fill anything browser-specific in an effect.
Catch it with ESLint
No lint rule can tell whether a framework passes the form state along.
no-client-only-initial-state and
no-storage-in-initial-render
catch form defaults read from the browser.
Catch it in CI
hydration-proof test compares every form control's value, checked and
selected state in the server HTML with what React rendered, including the
differences React never reports. To exercise a submission before hydration,
use interactions: they can type and click while the
page's scripts are held back.
npx hydration-proof test