# Fix Server Action form state hydration

> Server Action form state hydration breaks when the server renders a submitted form and the client hydrates without its state. Let the framework pass it on.

Source: https://hydration.jscrate.dev/docs/causes/form-state
Last updated: 2026-09-18

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](https://hydration.jscrate.dev/docs/issues/hp1012), a warning:

```text
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](https://hydration.jscrate.dev/docs/causes/browser-api).

## 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:

```tsx title="client.tsx"
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:

```tsx title="app/newsletter/signup.tsx"
"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`](https://hydration.jscrate.dev/docs/rules/no-client-only-initial-state) and
[`no-storage-in-initial-render`](https://hydration.jscrate.dev/docs/rules/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](https://hydration.jscrate.dev/docs/interactions): they can type and click while the
page's scripts are held back.

```bash
npx hydration-proof test
```

## Related

- [HP1012: form state differs between server and client](https://hydration.jscrate.dev/docs/issues/hp1012)
- [Server and client data that differ](https://hydration.jscrate.dev/docs/causes/data)
- [Browser APIs in render](https://hydration.jscrate.dev/docs/causes/browser-api)
- [Interactions and navigation](https://hydration.jscrate.dev/docs/interactions)
- [All causes of hydration errors](https://hydration.jscrate.dev/docs/causes)
