Reading localStorage in useState, or anywhere else in render, gives the two
renders different values: the server has no storage and renders the default,
while the browser renders the stored value during hydration.
no-storage-in-initial-render reports every localStorage and
sessionStorage read in render code, including state initializers.
| Rule | hydration-proof/no-storage-in-initial-render |
|---|---|
| What it reports | Disallow reading localStorage or sessionStorage while a component renders, including state initializers |
| recommended / next | Error |
| strict | Error |
| Server Components | Skipped with the next preset (they never hydrate) |
| Suggestions | No |
| Options | none |
What it reports
Any read of localStorage or sessionStorage (bare, or through window.,
self., globalThis.) in render code,
including the initial values of useState, useReducer, useRef and class
component state. Reads behind a typeof window !== 'undefined' check are
reported too: the check keeps the server from crashing, but the two renders
still differ. typeof localStorage on its own is an environment check and
belongs to no-window-render-branch.
Why localStorage in useState breaks hydration
The server has no storage, so it renders the default. The browser renders the stored value during hydration:
server HTML: <html class="light"> (no stored theme)
client render: <html class="dark"> (localStorage.theme === 'dark')React 19 keeps the server's class="light" without a warning in production,
so the page stays in the wrong theme until something re-renders it. A text
difference makes React discard the server HTML and render again.
Incorrect
function ThemeToggle() {
const [theme, setTheme] = useState(
() => localStorage.getItem("theme") ?? "light"
);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme}
</button>
);
}
function Draft() {
const saved =
typeof window !== "undefined" ? sessionStorage.getItem("draft") : "";
return <textarea defaultValue={saved} />;
}Correct
// Start from the default, then load the stored value.
function ThemeToggle() {
const [theme, setTheme] = useState("light");
useEffect(() => {
setTheme(localStorage.getItem("theme") ?? "light");
}, []);
return (
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
{theme}
</button>
);
}
// Or subscribe to storage with a server snapshot.
function useStoredTheme() {
return useSyncExternalStore(
subscribeToStorage,
() => localStorage.getItem("theme") ?? "light",
() => "light"
);
}For a theme that must be right before the first paint, store it in a cookie
and read it on the server, or set the class with an inline script before
React loads and put suppressHydrationWarning on <html>.
Theme hydration mismatches walks through both.
Options
This rule has no options.
Messages
What ESLint prints for this rule, word for word:
<read>is read during render. Storage only exists in the browser, so the server renders without the stored value and hydration sees a different result. Render the default first and read storage in useEffect, or use useSyncExternalStore with a getServerSnapshot.- The initial value of
<hook>reads<read>. Storage only exists in the browser, so the server renders the default while the browser starts from the stored value and hydration does not match. Start from the default and load the stored value in useEffect.
When not to use it
In components that are never server-rendered.
Related
no-client-only-initial-statecovers other browser values in initial state.require-stable-server-snapshotreports storage reads insidegetServerSnapshot.- localStorage and sessionStorage read during render: the cause and its fixes
- Zustand persist hydration errors, the same problem inside a store