For weeks, the saved-articles feature in one of my side projects worked fine. useState held the list, a save button pushed to it, a sidebar rendered it. Every manual test passed. Then a user reported that saving an article in one tab made it vanish from the sidebar in another tab — and sometimes an article they'd just unsaved would come back a few seconds later, unsaved-then-resaved, like the app couldn't make up its mind.
I couldn't reproduce it for a day. I only had one tab open. That should have been my first clue.
The setup that looked fine
The saved-articles state lived exactly where every React tutorial tells you to put it:
function useSavedArticles() {
const [saved, setSaved] = useState(() => {
const raw = localStorage.getItem("saved-articles");
return raw ? JSON.parse(raw) : [];
});
const save = (article) => {
setSaved((prev) => {
const next = [...prev, article];
localStorage.setItem("saved-articles", JSON.stringify(next));
return next;
});
};
return { saved, save };
}
Read from localStorage on mount, write to it on every change. This is the pattern I'd copy-pasted from a dozen "persist state with localStorage" articles, and it works — for exactly one tab.
Where it breaks
localStorage is shared across every tab on the same origin. useState is not. Each tab has its own React tree, its own saved state, its own copy of the array sitting in memory.
So the sequence that broke it looked like this:
- Tab A: user saves Article 1 →
saved = [1]in Tab A's memory,localStoragenow says[1]. - Tab B was already open before that. Its
savedstate was initialized fromlocalStoragewhen it first mounted — which was empty. Tab B's memory still sayssaved = []. - Tab B: user unsaves nothing, does nothing — but then saves Article 2. Tab B computes
nextfrom its own staleprev, which is[], so it writes[2]tolocalStorage— silently overwriting Article 1. - Tab A, still showing
[1]in its own memory, looks "correct" locally. Refresh Tab A, and Article 1 is gone, becauselocalStorageonly ever had[2].
Nothing in either tab was ever wrong, in isolation. The bug wasn't a logic error — it was two independent sources of truth that agreed by coincidence during testing and diverged under real use, because I always tested with one tab and the user always worked with several.
The fix: listen for the event you're already causing
The browser actually tells you when another tab changes localStorage — it's just an event most of us never wire up, because single-tab testing never fires it. window emits a storage event on every tab except the one that made the write.
function useSavedArticles() {
const [saved, setSaved] = useState(() => readSaved());
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === "saved-articles") {
setSaved(readSaved());
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
const save = (article) => {
setSaved((prev) => {
const next = [...prev, article];
localStorage.setItem("saved-articles", JSON.stringify(next));
return next;
});
};
return { saved, save };
}
function readSaved() {
const raw = localStorage.getItem("saved-articles");
return raw ? JSON.parse(raw) : [];
}
Now when Tab A writes, Tab B's storage listener fires and re-reads from localStorage, replacing its stale in-memory copy instead of computing a new one from state it never updated. The write in step 3 above now happens against Tab B's current data, not the snapshot it took when it first mounted.
BroadcastChannel is the newer, cleaner alternative if you want structured messages instead of parsing whatever landed in a storage key — but for a simple "resync on external change" case, the storage event needs no extra API and works in every browser that already supports localStorage.
The actual lesson
useState scopes to a component tree. A component tree scopes to a tab. localStorage scopes to an origin. The moment your persistence layer is broader than your state's scope, "keep them in sync" stops being optional — it's a requirement you inherited the second you chose localStorage, whether or not you noticed choosing it.
I hadn't noticed. I'd been testing the save button, not the two things it was supposed to keep in agreement.
I write about things that break while I build Boolflow and RealFeedApp. If you've solved cross-tab sync differently — BroadcastChannel, a shared worker, something else — I'd like to hear it.
Top comments (0)