u/Put-Scary

▲ 1 r/react+1 crossposts

A hydration-safe localStorage pattern that silently deleted user data on direct page loads

Spent a while chasing this one and the mechanism surprised me, so writing it up in case it saves someone else.

The setup — two things that are each fine

Like a lot of apps, we store user data (saved TV shows) in localStorage. The naive way to load it causes a hydration mismatch:

// server renders [], returning user's browser renders their real data const [favorites, setFavorites] = useState(() => JSON.parse(localStorage.getItem("favorites")) ); So we did the standard fix — start empty on both sides so the first render matches, then load the real value in an effect:

const [favorites, setFavorites] = useState([]);

useEffect(() => { const stored = localStorage.getItem("favorites"); if (stored) setFavorites(JSON.parse(stored)); }, []); Separately, the favorites page refreshes stale data on mount:

useEffect(() => { if (Date.now() - lastRefresh > TWELVE_HOURS) { refreshFavorites(); // maps over favorites, writes result to localStorage } }, []); Both reasonable. Together, they delete your data.

Why

React runs effects bottom-up — children before parents.

Land directly on /favorites (bookmark, refresh, external link) and the context provider and the page mount in the same commit. So:

Page effect runs first, calls refreshFavorites() That reads favorites from its closure — still [], because the provider's effect hasn't run yet It refreshes zero shows, gets zero back, writes [] to localStorage Provider effect runs, reads localStorage, finds [] — because it now genuinely is Why it never showed up in dev

Navigating client-side, the provider is already mounted and hydrated. The page mounts alone, favorites is populated, everything works.

It only reproduces on a fresh load of that specific URL. Which is the path a returning user takes, and the path you basically never take while building.

The fix

A flag that distinguishes "empty" from "not loaded yet":

const [hydrated, setHydrated] = useState(false);

useEffect(() => { try { const stored = localStorage.getItem("favorites"); if (stored) setFavorites(JSON.parse(stored)); } finally { setHydrated(true); // runs even if the parse throws } }, []); Page waits for it:

useEffect(() => { if (!hydrated) return; // ...refresh }, [hydrated]); The takeaway

Deferring initialisation to fix hydration creates a window where your state is legitimately untrue. That's fine — unless something else runs inside that window and can't tell the difference between "no data" and "not loaded yet."

Curious whether others have hit this. It feels like the kind of thing that's latent in a lot of Context + localStorage setups.

Full write-up with the whole story: https://watchnext.leyu.studio/blog/hydration-race-deleted-favorites

reddit.com
u/Put-Scary — 6 days ago