hydration-safe-inputs
npx skills add https://github.com/ethanniser/hydration-test --skill hydration-safe-inputs
Agent 安装分布
Skill 文档
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 ""; // server fallback
});
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(() => ...)notuseState(...) - Guard for SSR – Check
typeof window !== "undefined" - Type check the element – Use
instanceof HTMLInputElement(orHTMLTextAreaElement,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:
// VULNERABLE 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 inpages/directory -
Components without hydration-safe initialization – Missing the
typeof windowguard pattern
Refactoring Checklist
When fixing an existing component:
- Add unique
idto the input element - Replace
useState(defaultValue)withuseState(() => { ... }) - Add window check and DOM query in initializer
- Add appropriate
instanceoftype 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];
}
// Usage:
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:
// Add to layout to simulate slow hydration
<script src="/api/slow-script" /> // endpoint that delays 2-3 seconds