We refactored Favorites into a single, predictable React hook that works for both anonymous and verified users, avoids infinite re-renders, and keeps multiple tabs in sync — without peppering pages with ad-hoc effects.
Anonymous / unverified users
Store favorites in localStorage
(limit 50)
Cross-tab updates (changes in one tab appear in others)
Verified users
Store favorites in the database (limit 200)
Auto-fetch on login (once per user)
Optimistic add/remove
First-login one-way merge
Local localStorage
→ account (handled elsewhere)
Simple page API
Pages shouldn’t do fetches or wire up listeners. They should just read favorites
and render.
Infinite re-render loop
We initially wrote an effect like:
useEffect(() => {
(async () => {
const list = await fetchRemoteFavorites(userSub);
setRemoteFavs(list);
})();
}, [userSub, remoteFavs]);
Including remoteFavs
in the dependency array caused the effect to re-run every time we called setRemoteFavs
, which called the effect again… → “Maximum update depth exceeded”.
Overexposed API (getFavorites
)
We exported a getFavorites()
function that sometimes fetched, sometimes read cache/local — and pages were calling it inside effects. That created dependency tangles and duplicated fetch logic.
Cross-tab sync outside the hook
We initially put a storage
event listener in the page to refresh local favorites. That tied component code to storage mechanics and reintroduced dependency loops.
Fetch: The hook auto-fetches once per verified userSub
inside useEffect([userSub])
.
Cross-tab sync: The hook listens to the storage
event internally when not verified.
Pages don’t fetch. They just read favorites
and loading
.
getFavorites
No more getFavorites()
. Returning favorites
directly from the hook makes the page a pure consumer — it just subscribes to updates by re-rendering.
For local users, writes to localStorage
don’t automatically cause a React re-render. We increment a tiny state counter (localVersion
) after local writes (and on storage
events) to re-render the component tree. On the next render, the hook reads the latest localStorage
and surfaces the new list.
In other words, components subscribing to the hook’s state see updates without wiring their own listeners. (“Listening” in React is just re-rendering when the hook’s state changes.)
/**
* useFavorites
* - Anonymous/unverified: localStorage (max 50), cross-tab via 'storage'
* - Verified: DB, auto-fetch once per userSub, optimistic add/remove
* - Returns: { favorites, loading, isFavorite, addFavorite, removeFavorite }
*/
import { useEffect, useState } from "react";
import { useUser } from "@auth0/nextjs-auth0/client";
import { queueAddFavorite, queueRemoveFavorite, fetchRemoteFavorites } from "../helpers/favoritesQueue";
const STORAGE_KEY = "SS-FAVORITES-V2";
const MAX_LOCAL = 50;
/* localStorage helpers */
function readLocal() {
if (typeof window === "undefined") return [];
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); } catch { return []; }
}
function writeLocal(list) {
if (typeof window === "undefined") return;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); } catch {}
}
export function useFavorites() {
const { user } = useUser();
const userSub = user?.email_verified ? user?.sub : null;
// For verified users: null = loading, []|[...] = loaded
const [remoteFavs, setRemoteFavs] = useState(null);
// For local users: bump to force re-render after local writes or storage events
const [localVersion, setLocalVersion] = useState(0);
// Auto-fetch once per verified sub
useEffect(() => {
if (!userSub) {
// Leaving verified state → clear once
setRemoteFavs((prev) => (prev === null ? prev : null));
return;
}
let alive = true;
(async () => {
try {
const list = await fetchRemoteFavorites(userSub);
if (alive) setRemoteFavs(Array.isArray(list) ? list : []);
} catch {
if (alive) setRemoteFavs([]);
}
})();
return () => { alive = false; };
}, [userSub]);
// Cross-tab sync for local/unverified: bump to re-render and re-read localStorage
useEffect(() => {
if (userSub) return;
const onStorage = (e) => {
if (e.key === STORAGE_KEY) setLocalVersion((v) => v + 1);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [userSub]);
// Derived outputs
const loading = !!userSub && remoteFavs === null;
const favorites = userSub
? Array.isArray(remoteFavs) ? remoteFavs : []
: readLocal(); // re-evaluated on each render; localVersion triggers re-render
function isFavorite(id) {
if (!id) return false;
return Array.isArray(favorites) && favorites.some((it) => it.id === id);
}
function addFavorite(item) {
if (!item || !item.id) return;
if (userSub) {
// optimistic replace/prepend
setRemoteFavs((prev) => {
const base = Array.isArray(prev) ? prev : [];
const filtered = base.filter((x) => x.id !== item.id);
return [item, ...filtered];
});
queueAddFavorite(item, userSub); // fire-and-forget
} else {
const current = readLocal().filter((x) => x.id !== item.id);
writeLocal([item, ...current].slice(0, MAX_LOCAL));
setLocalVersion((v) => v + 1); // trigger re-render so subscribers update
}
}
function removeFavorite(id) {
if (!id) return;
if (userSub) {
setRemoteFavs((prev) => {
const base = Array.isArray(prev) ? prev : [];
return base.filter((x) => x.id !== id);
});
queueRemoveFavorite(id, userSub);
} else {
writeLocal(readLocal().filter((x) => x.id !== id));
setLocalVersion((v) => v + 1); // trigger re-render so subscribers update
}
}
return { favorites, loading, isFavorite, addFavorite, removeFavorite };
}
No getFavorites
: Pages don’t call into the hook to fetch or read snapshots. They just subscribe by destructuring { favorites, loading }
.
No dependency loops: The only fetching effect depends solely on userSub
, so setRemoteFavs
doesn’t retrigger it.
Cross-tab for free: The hook owns the storage
listener; pages don’t wire up anything. When a change happens elsewhere, we bump localVersion
to re-render and re-read localStorage
.
Optimistic UX: Adding/removing updates the in-memory state immediately for verified users, while the debounced queue writes to the server.
HTTP 201 vs 200: If your fetch endpoint is POST /users/favorites
, a 201 Created response is expected. If you want read semantics, expose a GET /users/favorites
that returns 200 OK.
Alive guard: We keep a tiny alive
flag to avoid “setState on unmounted” warnings if the user navigates away mid-fetch.
Terminology: React components don’t “listen” in the traditional event sense — they re-render when the hook’s internal state changes. Thinking of it as “subscribing” to the hook’s state is a good mental model.