I found this by accident, while chasing something else entirely. Testing an unrelated date bug, I loaded the Favorites page directly by URL — and the test data I'd just saved was gone. Not "failed to display". Gone from storage.
The strange part: clicking through to the same page from inside the app worked perfectly, every time. Same page, same code, same data. The only difference was how you arrived.
That asymmetry turned out to be the entire explanation.
Two reasonable decisions
The first piece is a hydration fix. The app stores your saved shows in localStorage. The obvious way to load them is to read storage when the state is first created:
const [favorites, setFavorites] = useState(() =>
JSON.parse(localStorage.getItem("favorites"))
);
That works, until you server-render. The server has no localStorage, so it renders an empty list. A returning visitor's browser does have the data, so it renders a full one. React compares the two, finds they disagree, and complains about a hydration mismatch.
The standard fix — and the one I'd applied earlier — is to stop reading storage during render. Start empty on both server and client so the first render matches, then load the real data in an effect afterwards:
const [favorites, setFavorites] = useState([]);
useEffect(() => {
const stored = localStorage.getItem("favorites");
if (stored) setFavorites(JSON.parse(stored));
}, []);
The second piece is ordinary product behaviour: when you open Favorites, if the show data hasn't been refreshed in twelve hours, fetch fresh details for each saved show and store the result.
useEffect(() => {
if (Date.now() - lastRefresh > TWELVE_HOURS) {
refreshFavorites(); // maps over `favorites`, writes result to localStorage
}
}, []);
Both correct. Both, in isolation, uncontroversial.
Where they collide
React runs effects from the bottom of the tree upward. Children first, then their parents.
When you land directly on the Favorites page, the app's data provider and the page itself mount together, in the same commit. So:
- The page's effect runs first. It checks the timestamp, decides a refresh is due, and calls the refresh function.
- That function reads the current list of favorites — which, at this exact moment, is still the empty array everything started as, because the provider's effect (a parent effect) hasn't run yet.
- So it refreshes a list of zero shows. It receives zero shows back. It writes that result to
localStorage, overwriting whatever was there. - A moment later the provider's effect runs, reads storage to restore your favorites, and finds an empty array. Because it just was one.
The hydration fix wasn't the mistake — it was the right call, and reverting it would just bring back the bug it solved. The mistake was not noticing that deferring the load created a window where the data legitimately isn't there yet, and that something else was already running inside that window.
Why normal use never showed it
Navigate to Favorites by clicking a link and the provider is already mounted from whatever page you were on. Its effect ran long ago. The favorites are loaded. The page mounts alone, the refresh reads a populated list, and everything works.
The bug needs the provider and the page to mount in the same commit, which only happens on a fresh load of that specific URL: a bookmark, a refresh while sitting on the page, a link shared from outside, or reopening a tab.
Which is a genuinely unpleasant profile for a data-loss bug. It skips the path developers use constantly while building — clicking around a running app — and hits the path a returning user is most likely to take. Someone who bookmarks the page they care about is exactly the person with the most to lose.
The fix, and a better one from the comments
My first fix was a flag distinguishing "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
}
}, []);
with the page waiting on it before refreshing.
That works. But when I posted this, a commenter pointed out it's weaker than it looks: it's a guard every future caller has to remember to check — structurally the same shape as the original bug, correct only as long as nobody forgets.
The stronger version: make the refresh read storage directly rather than trusting React state.
const refreshFavorites = useCallback(async () => {
const raw = localStorage.getItem("favorites");
const saved = raw ? JSON.parse(raw) : [];
if (saved.length === 0) return;
// ...refresh `saved`, write the result back
}, []); // no dependency on component state at all
Now there is no ordering in which the refresh can observe fewer items than are actually saved. The overwrite isn't guarded against — it's impossible. I removed the flag from the write path entirely and re-tested the original failure scenario with the guard gone, to confirm the safety was structural rather than conditional.
The same commenter made a second point I'd missed: the empty state was rendering "No favorites added yet" during that same pre-hydration window — telling a returning user their list was gone, a moment before it appeared. "Empty" and "not loaded yet" have to be distinguishable when you render, too, not just when you write.
What I'd take from it
Deferring work to fix one problem creates a window where your state is temporarily untrue. That's fine, as long as nothing else acts during it. Worth asking, whenever you move initialisation into an effect: what else runs before this, and what will it think the state means?
More generally: "empty" and "not loaded yet" looking identical is a recurring source of this kind of damage. If code can act on the difference, it needs to be able to see the difference.
And the reason I found it at all is that I loaded a page the way a user would, rather than the way I always did.
Top comments (0)