| name | hydration-safe-inputs |
| description | Fix React hydration issues where user input typed before hydration gets wiped/cleared when React takes over. Use when (1) users report input fields clearing on page load, (2) working with SSR/SSG React apps (Next.js, Remix, Astro) that have controlled inputs, (3) auditing forms for hydration safety, or (4) building new forms in statically rendered React apps. |
Hydration-Safe Inputs
The Problem
In SSR/SSG React apps, there's a window between when HTML renders and when React hydrates. If a user types into an input during this window, React's hydration will wipe their input because React initializes state to the default value (usually empty string).
Timeline:
1. HTML arrives → input rendered (empty)
2. User types "hello" → input shows "hello"
3. React hydrates → useState("") runs → input wiped to ""
The Fix
Initialize state by reading the DOM element's current value instead of a hardcoded default:
function Input() {
const [value, setValue] = useState(() => {
if (typeof window !== "undefined") {
const input = document.getElementById("my-input");
if (input instanceof HTMLInputElement) {
return input.value;
}
}
return "";
});
return (
<input
id="my-input"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
);
}
Key Requirements
- Element needs an
id - The initializer must find the element
- Use lazy initializer -
useState(() => ...) not useState(...)
- Guard for SSR - Check
typeof window !== "undefined"
- Type check the element - Use
instanceof HTMLInputElement (or HTMLTextAreaElement, HTMLSelectElement)
Patterns by Input Type
Text Input
const [value, setValue] = useState(() => {
if (typeof window !== "undefined") {
const el = document.getElementById("my-input");
if (el instanceof HTMLInputElement) return el.value;
}
return "";
});
Checkbox
const [checked, setChecked] = useState(() => {
if (typeof window !== "undefined") {
const el = document.getElementById("my-checkbox");
if (el instanceof HTMLInputElement) return el.checked;
}
return false;
});
Select
const [value, setValue] = useState(() => {
if (typeof window !== "undefined") {
const el = document.getElementById("my-select");
if (el instanceof HTMLSelectElement) return el.value;
}
return "default";
});
Textarea
const [value, setValue] = useState(() => {
if (typeof window !== "undefined") {
const el = document.getElementById("my-textarea");
if (el instanceof HTMLTextAreaElement) return el.value;
}
return "";
});
Identifying Vulnerable Components
Search for these patterns that indicate potential hydration wipe issues:
-
Controlled inputs with hardcoded initial state:
const [value, setValue] = useState("");
return <input value={value} onChange={...} />;
-
Form components in SSR/SSG pages - Any "use client" component with controlled inputs in Next.js App Router, or any component in pages/ directory
-
Components without hydration-safe initialization - Missing the typeof window guard pattern
Refactoring Checklist
When fixing an existing component:
- Add unique
id to the input element
- Replace
useState(defaultValue) with useState(() => { ... })
- Add window check and DOM query in initializer
- Add appropriate
instanceof type guard
- Keep original default as SSR fallback
Custom Hook (Optional)
For apps with many inputs, extract to a reusable hook:
function useHydrationSafeValue<T>(
id: string,
defaultValue: T,
extract: (el: Element) => T
): [T, React.Dispatch<React.SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
if (typeof window !== "undefined") {
const el = document.getElementById(id);
if (el) return extract(el);
}
return defaultValue;
});
return [value, setValue];
}
const [value, setValue] = useHydrationSafeValue(
"my-input",
"",
(el) => (el as HTMLInputElement).value
);
Testing
To verify the fix works:
- Add artificial hydration delay (slow network or blocking script)
- Type into the input before hydration completes
- Confirm input value persists after hydration
Example delay script for testing:
<script src="/api/slow-script" />