Building a Robust useFavorites Hook (and the bugs we fixed)
Adam C. |

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.

Photo by maryam Rad on Unsplash

What we needed

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.

The early issues

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.

The final design (simple + reliable)

1) Hook owns all side effects

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.

2) Hide getFavorites

No more getFavorites(). Returning favorites directly from the hook makes the page a pure consumer — it just subscribes to updates by re-rendering.

3) Use a “local count” to trigger re-renders

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

The hook (final)

/**
 * 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 };
}

Why this works

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.

Extra notes

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.