React Hooks: Why This Codebase's `useContext` Count Is Zero
24 useState, 13 useEffect, 0 useContext across 22 files — a real hook census, and the module-scope store pattern this codebase uses instead of Context.
A React hook is a plain function — always named useSomething, always called at a component's top level, never inside a condition or loop — that lets a function component hold state, run side effects, or read context without ever being written as a class. This codebase's 22 client-side files that use hooks skip two of React's most commonly reached-for ones almost entirely: useContext appears zero times, and useMemo appears twice. What replaces them, and why, is a more useful tour of hooks than another walkthrough of useState.
The census
$ grep -ro 'useState(' src --include="*.tsx" --include="*.ts" | wc -l
24
$ grep -ro 'useEffect(' src --include="*.tsx" --include="*.ts" | wc -l
13
$ grep -ro 'useContext(' src --include="*.tsx" --include="*.ts" | wc -l
0
| Hook | Occurrences | What it's doing here |
|---|---|---|
useState | 24 | Form fields, modal/menu open state, loading flags |
useEffect | 13 | Fetching ownership data, syncing focus traps, body-scroll locks |
useCallback | 4 | Stabilizing handlers passed to memoized children |
useRef | 2 | A mounted-guard and a "have we auto-opened this once" flag |
useId | 2 | Linking a label to a field, an FAQ button to its panel |
useSyncExternalStore | 2 | Reading localStorage without a hydration mismatch |
useTransition | 1 | A pending state around a Server Action, without blocking the UI |
useMemo | 2 | Deriving a filtered product list from props |
useContext | 0 | Not used anywhere in this codebase |
Two custom hooks carry the interesting logic — src/lib/useOwnedProducts.ts and src/lib/useWishlist.ts — and both exist specifically to avoid patterns the built-in hooks would otherwise push toward.
useOwnedProducts: one fetch, shared by every card on the page
A templates grid renders a dozen product cards, and every one wants to know whether the signed-in visitor already owns that product. Calling a hook naively in each card would mean a dozen separate getUser() round trips returning the same answer:
// src/lib/useOwnedProducts.ts
let lookupPromise: Promise<Omit<OwnedProducts, "status">> | null = null;
function loadOwnership(): Promise<Omit<OwnedProducts, "status">> {
lookupPromise ??= (async () => {
if (!hasAuthCookie()) return NOBODY;
const supabase = await loadSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NOBODY;
return { signedIn: true, slugs: await getOwnedProductSlugs() };
})().catch((error) => {
console.error("useOwnedProducts: ownership lookup failed", error);
lookupPromise = null;
return NOBODY;
});
return lookupPromise;
}
export function useOwnedProducts(): OwnedProducts {
const [state, setState] = useState<OwnedProducts>({
status: COMMERCE_ENABLED ? "loading" : "ready",
...NOBODY,
});
useEffect(() => {
if (!COMMERCE_ENABLED) return;
let cancelled = false;
void loadOwnership().then((result) => {
if (!cancelled) setState({ status: "ready", ...result });
});
return () => { cancelled = true; };
}, []);
return state;
}
The promise lives at module scope, outside the hook entirely — every card's useOwnedProducts() call awaits the same in-flight promise instead of starting a new lookup. useState and useEffect here are doing the ordinary job (kick off work on mount, store the result), but the fan-out-avoidance is the module-level cache, not a hook feature. The cancelled flag guards the one real race: if the component unmounts before the promise resolves, setState on an unmounted component never fires. And the whole lookup is skipped outright when COMMERCE_ENABLED is false — a build without Supabase env vars configured never calls getUser() at all, so an anonymous visitor or a crawler never pays for ~68 KiB of auth-client JavaScript they were never going to use.
useWishlist: reading localStorage without breaking hydration
The heart button on a product card, the header's wishlist count, and the /saved page all need to agree on the same list, live, with no server round trip — localStorage is the obvious store, and it's exactly the case useSyncExternalStore exists for:
// src/lib/useWishlist.ts
export function useWishlist() {
const entries = useSyncExternalStore(
subscribeWishlist,
readWishlist,
getServerWishlistSnapshot,
);
const hydrated = useSyncExternalStore(
subscribeNever,
() => true,
() => false,
);
const toggle = useCallback((entry: WishlistEntry) => {
toggleWishlist(entry);
}, []);
// ...
return { entries, hydrated, toggle, /* ... */ };
}
The naive version of this — reading localStorage inside useState's initializer, or in a useEffect that calls setState after mount — either throws during server rendering (no localStorage on the server) or renders the real value on the client one tick after an empty first paint, which is a visible flash for a wishlist count. useSyncExternalStore's third argument is a server snapshot: React uses it for the initial HTML and swaps to the real client value during hydration, correctly, with no extra render and no useEffect needed just to read a browser API. The second useSyncExternalStore call — subscribing to a store that never fires and returning false on the server, true on the client — is the standard idiom for "has hydration finished," used here to hold back a 0 badge from flashing before storage is actually read.
Why useContext is zero, not just low
The wishlist state needs to reach the heart button on every card, the header's badge, and a side-sheet — three unrelated parts of the tree, which is the textbook case for a Context provider. This codebase solves it with a module-scope store (src/lib/wishlist.ts) plus useSyncExternalStore instead: every consumer subscribes directly to the same store, no <WishlistProvider> wraps anything, and there's no provider re-render to worry about when one consumer's slice of the data changes. useOwnedProducts makes the identical choice for ownership data, with a plain in-memory promise standing in for the store. Two independent problems, one avoided abstraction each — which is why the count sits at zero rather than "low": nothing in this codebase reaches for cross-tree shared state through Context at all, and nothing so far has needed to.
Which hook actually solves which problem
| Need | Reached for here | Why not the alternative |
|---|---|---|
| State inside one component | useState | — |
| A one-time async fetch on mount | useState + useEffect | useOwnedProducts also needs a module-scope cache to fan out; the hooks alone only cover the single-component case |
Reading a browser-only store (localStorage) | useSyncExternalStore | useState initialized from localStorage throws on the server; a useEffect-driven setState causes a visible post-hydration flash |
| State shared across unrelated components | A module-scope store + useSyncExternalStore | useContext works too, but needs a provider wrapping the tree and re-renders every consumer on any change |
| A pending flag around a Server Action | useTransition | A plain useState boolean works, but doesn't integrate with React's built-in pending/transition priority the way useTransition does |
| Linking a label to a field, or a button to its panel | useId | A hardcoded string id breaks the moment the same component renders twice on one page |
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Reading localStorage inside useState's initializer | Throws during server rendering — no localStorage on the server | Use useSyncExternalStore with a server-snapshot argument, or delay the read to a useEffect |
A useEffect that reads localStorage and calls setState | Renders empty on first paint, then flashes to the real value after hydration | useSyncExternalStore's third argument supplies the correct server-render value with no extra render |
One useOwnedProducts-style fetch per card, no shared cache | A grid of 12 cards fires 12 identical network round trips | Cache the in-flight promise at module scope; every hook call awaits the same one |
setState after a component unmounts | React warning in dev; a real memory-adjacent bug in production | Track a cancelled flag in the effect's cleanup and check it before calling setState |
Reaching for useContext + a provider for state only two or three components need | A provider wraps a large subtree just to pass one value down | A module-scope store with useSyncExternalStore reaches the same components without wrapping anything |
Frequently asked questions
What's the actual rule for where a hook can be called?
Only at a function component's (or another hook's) top level — never inside if, a loop, or after an early return. React tracks hook state by call order across renders, not by name, so a hook that sometimes runs and sometimes doesn't shifts every hook after it out of alignment.
Is a custom hook just a regular function with use in the name?
Structurally yes — useOwnedProducts and useWishlist are ordinary functions that happen to call other hooks internally. The use prefix is a convention, not a language feature, but React's lint rules and DevTools both rely on it to know a function follows hook rules.
Why does useWishlist call useSyncExternalStore twice?
Two different questions need answering: "what are the current entries" (the first call, backed by the real store) and "has the client taken over from the server render yet" (the second call, backed by a store that never notifies and simply differs between server and client). Both need the same hydration-safe mechanism, so both go through the same hook.
If this codebase avoids useContext, does that mean Context is bad?
No — Context is the right tool when a value genuinely belongs to a whole subtree (a theme, a locale, an authenticated user object read in dozens of unrelated places). It's the wrong default for state with a small, known set of consumers, which is the shape both useOwnedProducts and useWishlist happen to have here.
Templates in this post
ASoc Sentinel (a security-software landing page), ASoc Signal (an AI voice & image studio site) and ASoc Sterling (a wealth-management marketing site) all ship product cards and wishlist buttons built on the same useOwnedProducts/useWishlist hooks audited above.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the Suspense boundaries this codebase decided it doesn't need either, see React Suspense; for the session check that sits behind useOwnedProducts, Auth in React.
