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-modelbinds the input; the reactive value (empty) is applied on mount, replacing the DOM value in many configurations. - Svelte hydration with
bind:valuelikewise 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 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
- 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.
- Prefer uncontrolled inputs for those fields. If the field does not need per-keystroke state,
defaultValueplus reading on submit (as in reading values with FormData on submit) sidesteps the problem entirely. - For controlled fields, adopt at hydration. Compare the DOM value with the server-rendered initial value in a layout effect (or
onMountedin Vue,$effect.prein Svelte) and copy it into state if different. - Handle checkboxes, radios and selects too. Compare
checkedandselectedIndex, not justvalue; users tick boxes before hydration as readily as they type. - Make early submits safe. A form with a real
actionURL 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. - Preserve focus and selection. If adoption causes a re-render, restore
selectionStart/selectionEndso 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.
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.
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.