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.
getFavoritesNo 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.