On a slow phone, a server-rendered form is visible and editable for one to several seconds before its JavaScript hydrates — and when hydration attaches controlled inputs whose state starts empty, it overwrites whatever the user already typed, so the first few words of their message simply vanish.

Hydration sync for SSR forms covers mismatches between server and client render. This page covers the other hydration gap: the DOM changed because a person changed it, before the framework was listening. The fix is to treat the pre-hydration DOM as an input source — read it once at hydration, adopt it into state, and design early fields so there is nothing to overwrite.


Context and prerequisites

What each framework does to a text input at hydration:

  • React hydrates <input value={state}> by attaching to the existing element. For controlled inputs, React sets the DOM value from state on the next commit if they differ — and state started from the server’s initial value, usually empty. The user’s early text is replaced.
  • Vue hydration with v-model binds the input; the reactive value (empty) is applied on mount, replacing the DOM value in many configurations.
  • Svelte hydration with bind:value likewise initialises from the component’s state.
  • Uncontrolled inputs (defaultValue, no binding) are left alone: the DOM keeps what the user typed, and your code reads it later.

Browsers can also restore form values on back/forward navigation or session restore, which hits the same path: the DOM holds values the framework did not put there.

The window in which typing can be lost The HTML paints at about 800 milliseconds and the form is usable. The user focuses the message field and types from 1.2 to 3.4 seconds. The JavaScript bundle downloads and executes until about 3.6 seconds, when hydration runs. With controlled inputs initialised from empty state, the typed text is replaced at hydration. With the adoption step, the typed text is read into state first and kept. Page painted, usable, not hydrated hydrated User types the first sentence Controlled, no adoption text replaced by empty state With adoption text read into state, kept 0ms 1000ms 2000ms 3000ms 4000ms 5000ms

The core pattern: adopt DOM values at hydration

import { useLayoutEffect, useRef, useState } from "react";

/**
 * Controlled field that adopts any value the user typed before hydration.
 * useLayoutEffect runs after hydration attaches but before the browser paints
 * the next frame, so the user never sees their text disappear and reappear.
 */
export function useAdoptedValue(serverInitial: string) {
  const ref = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
  const [value, setValue] = useState(serverInitial);

  useLayoutEffect(() => {
    const el = ref.current;
    // If the DOM differs from what the server rendered, a person (or the
    // browser's form restoration) changed it before we were listening.
    if (el && el.value !== serverInitial) setValue(el.value);
    // Run once, at hydration.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return { ref, value, onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setValue(e.target.value) };
}

export function MessageField() {
  const f = useAdoptedValue("");
  return (
    <>
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" ref={f.ref as React.RefObject<HTMLTextAreaElement>} value={f.value} onChange={f.onChange} />
    </>
  );
}
<!-- Framework-agnostic: capture early submits before hydration -->
<script>
  // Inline in the <head>: runs before the bundle. If the user submits early,
  // let the native POST proceed only if the form has a real action; otherwise
  // hold the event until the app is ready.
  document.addEventListener("submit", (e) => {
    if (window.__appReady) return;
    const form = e.target;
    if (!form.hasAttribute("data-hold-until-ready")) return;   // native POST works: let it go
    e.preventDefault();
    window.__queuedSubmit = form;                              // the app re-dispatches on ready
  }, true);
</script>

Step-by-step walkthrough

  1. Decide which fields can be touched early. Anything above the fold on a server-rendered page — search boxes, login fields, the first fields of a form — is at risk.
  2. Prefer uncontrolled inputs for those fields. If the field does not need per-keystroke state, defaultValue plus reading on submit (as in reading values with FormData on submit) sidesteps the problem entirely.
  3. For controlled fields, adopt at hydration. Compare the DOM value with the server-rendered initial value in a layout effect (or onMounted in Vue, $effect.pre in Svelte) and copy it into state if different.
  4. Handle checkboxes, radios and selects too. Compare checked and selectedIndex, not just value; users tick boxes before hydration as readily as they type.
  5. Make early submits safe. A form with a real action URL posts natively before hydration — the most robust option, per progressive enhancement for server-rendered forms. Otherwise, capture the submit in an inline script and replay it when the app is ready.
  6. Preserve focus and selection. If adoption causes a re-render, restore selectionStart/selectionEnd so the caret does not jump to the end.

Why this is not a hydration mismatch

Framework hydration warnings compare the server-rendered markup with what the client would render. A value typed by the user changes the element’s value property, not its value attribute, so the markup still matches and no warning fires. That is what makes the bug so easy to ship: nothing in development tooling flags it, fast development machines hydrate before anyone can type, and the loss only happens on real devices under real network conditions. It has to be designed out and tested for deliberately.

Protecting a field from pre-hydration loss If the field does not need per-keystroke state, make it uncontrolled and read it on submit; nothing is overwritten. If it must be controlled, adopt the DOM value into state at hydration. Separately, if the form can have a real action URL, let pre-hydration submits post natively; otherwise capture them with an inline script and replay them once the app is ready. Needs per-keystroke state? Controlled + adopt at hydration yes no Can the form post natively? Real action URL yes no Capture and replay Inline script holds the submit until ready.

Failure modes and edge cases

1. Adopting in useEffect instead of useLayoutEffect

useEffect runs after paint: the user sees their text vanish for a frame and reappear. useLayoutEffect runs before paint. In frameworks with streaming or selective hydration, hydrate forms early (for React, avoid wrapping them in low-priority Suspense boundaries).

2. Browser form restoration

Back/forward navigation and restored tabs can repopulate fields with previous values. Adoption picks these up too — usually desirable, but for sensitive forms set autocomplete="off" on non-credential fields where restoration is unwanted.

3. Hydration mismatches caused by adoption

Adoption must happen after hydration, not during render: reading el.value during render on the client produces markup different from the server’s. Always read in an effect.

4. Validation state

Adopted values should be validated like typed values but not immediately shown as errors; mark them dirty, not touched, following touched vs dirty vs visited.

5. Testing the gap

Nothing reproduces this on a fast laptop. In Playwright, delay the bundle with page.route("**/*.js", …) for a few seconds, type into the field, release the bundle and assert the value survives.

Testing the pre-hydration window in Playwright The test routes script requests and holds them. It navigates to the page, which paints from server HTML. It types a sentence into the message field. It then releases the held scripts, waits for an application-ready signal, and asserts that the field still contains the typed sentence and that the app's state holds it too. Test Browser Held bundle route **/*.js: hold goto /contact (HTML paints) fill #message "Hello, I need…" release scripts hydrated: value still present

Verification checklist


Frequently Asked Questions

Does React 19 preserve typed values during hydration?

React has improved how it handles some pre-hydration events, such as replaying certain discrete events after hydration, but a controlled input still ends up with whatever its state says. If state starts empty, the DOM value is replaced. Adopting the DOM value, or using an uncontrolled input, is still the reliable approach.

Is it better to disable inputs until hydration?

No. Disabled-until-ready forms punish the users on slow devices whom server rendering was meant to help, and a script failure leaves the form unusable. Keep inputs usable and preserve what they receive.

Does this apply to islands or partial hydration?

Yes, and the window can be longer, because islands often hydrate on visibility or idle. A form island hydrated “on visible” can be typed into while it is visible but not yet hydrated. Hydrate form islands eagerly, or rely on uncontrolled inputs within them.


Related

← Hydration Sync for SSR Forms