React Caching: 2 useMemo Calls, and the One Cache That Mattered
Five caching layers, and the render layer is rarely the one that pays. Nine lines of module-scope promise removed a dozen round trips per page load.
"React caching" is not one technique. It's at least five, operating at different layers: memoizing a render (useMemo, useCallback, React.memo), deduping a request per render pass (cache), holding fetched data across mounts (a query library or your own module-scope promise), persisting to the browser, and caching the HTML itself. Picking the wrong layer is the usual reason the caching didn't help.
This codebase is a storefront with 92 components, 24 of them client components. It contains exactly 2 useMemo calls, 4 useCallback calls and 0 React.memo wrappers — and one piece of caching that removed a dozen network round trips per page load. The distribution is the point: almost none of the value was at the render layer.
The five layers, and what each one is for
| Layer | API | Fixes | Does not fix |
|---|---|---|---|
| Render memoization | useMemo, useCallback, React.memo | Recomputing an expensive value or re-rendering a subtree | Anything touching the network |
| Per-request dedupe | cache from react | The same server fetch called from five components in one render | Repeat work across requests |
| Cross-mount data cache | Module-scope promise, React Query, SWR | Every component mount refetching the same thing | Slow first load |
| Browser persistence | localStorage, IndexedDB | State surviving a reload | Sharing state between devices |
| Response/HTML cache | Static generation, ISR, CDN | Rendering the page at all | Per-user data |
Almost every "my caching didn't work" question is a layer mismatch — useMemo wrapped around something that was making a request anyway, or a data cache added to fix a render that was slow for a different reason.
The cache that actually mattered
A templates grid renders a dozen product cards. Every card wants to know one thing: does the viewer own this product, so should it show a download menu instead of a buy button? The naive shape is a hook per card, and a hook per card means a dozen getUser() round trips plus a dozen server actions all returning the same list.
The fix was not useMemo — memoizing inside each card would have given twelve independent caches of twelve identical answers. It was to move the cache above the components entirely, to module scope, where there is exactly one of it per page load. This is src/lib/useOwnedProducts.ts, unedited:
let lookupPromise: Promise<Omit<OwnedProducts, "status">> | null = null;
function loadOwnership(): Promise<Omit<OwnedProducts, "status">> {
lookupPromise ??= (async () => {
// No session cookie: skip the auth stack entirely rather than downloading
// ~68 KiB of it to be told what the cookie already said.
if (!hasAuthCookie()) return NOBODY;
const supabase = await loadSupabaseClient();
const {
data: { user },
} = await supabase.auth.getUser();
// Signed out: skip the server action entirely — it would return [] anyway.
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;
}
Three things in nine lines are worth copying.
It caches the promise, not the result. lookupPromise ??= means the second caller through the door gets the in-flight promise, not a second request. Caching the resolved value instead would still let twelve simultaneous mounts fire twelve requests before any of them finished — the exact stampede the cache exists to prevent.
The failure path clears the cache. lookupPromise = null inside .catch is deliberate. Caching a rejection would pin one transient network failure for the entire life of the page; clearing it means the next mount retries. A cache that remembers errors forever is worse than no cache.
The cheapest check runs first. hasAuthCookie() reads a cookie synchronously. If there's no session cookie there is nothing to look up, so the function returns before dynamically importing the Supabase client — about 68 KiB of auth stack that an anonymous visitor, or a crawler, never downloads. The fastest request is the one you can prove you don't need.
The lifetime is module lifetime, which is the correct one here: the two events that change ownership — completing a checkout, or signing in or out — both end in a full document load, which resets the module. No invalidation logic, because there is nothing to invalidate.
The hook also exports a test seam, which is the tell that module-scope state is a real cache and needs treating as one:
/** Test seam: drop the memoized lookup so the next hook mount refetches. */
export function resetOwnedProductsCache(): void {
lookupPromise = null;
}
Without that, test two inherits test one's cached answer and passes for the wrong reason.
Where the two useMemo calls are
Both live in src/components/organisms/YourProductsGrid.tsx, the dashboard's owned-products grid:
const categories = useMemo(() => ownedCategories(products), [products]);
const filtered = useMemo(
() =>
products.filter((p) =>
matchesProductFilter(p, activeCategory, searchText),
),
[products, activeCategory, searchText],
);
This is the shape useMemo is genuinely for: a derived list recomputed on every keystroke in a search box, where products is stable and the typing is what re-renders. Note the second one still recomputes on every keystroke — searchText is in the dependency array. It's memoized against re-renders caused by other state, not against the filtering itself.
Everything else in the codebase does the work inline. Two lines below the memos:
const nonBackendOwned = products.some((p) => p.category !== null);
That is an array scan on every render, uncached, and it is correct to leave it that way. A some() over a list of owned products costs less than the memo's own bookkeeping, and a wrapper there would be pure noise for the next reader.
What this codebase does not cache
Zero cache calls from React. cache dedupes a function across one server render pass — five components each calling getUser() become one call. It earns its place in a request-rendered app. Every page here is statically generated at build time and re-rendered by a deploy, not by a request, so there is no per-request fan-out to dedupe.
Zero revalidate exports and zero ISR. Grepping the whole of src/app for revalidate returns nothing. Product data lives in a typed file in the repository, so "the content changed" and "there is a new build" are the same event. ISR solves the case where those are different events; adding it here would buy nothing and add a staleness window.
Zero React.memo. Not a single component is wrapped. The site is 92 components and 74% of them are Server Components, which never re-render on the client at all. React.memo only pays when a parent re-renders often and a child's props genuinely don't change — a condition that needs measuring, and one this codebase has not hit.
The thing that looks like a cache and isn't
src/lib/useWishlist.ts reads localStorage through useSyncExternalStore:
const entries = useSyncExternalStore(
subscribeWishlist,
readWishlist,
getServerWishlistSnapshot,
);
It's tempting to call this caching — data is persisted and read back. It isn't. localStorage here is the source of truth, not a copy of one, and useSyncExternalStore is a subscription, not a cache: every heart button, the header count badge and the side sheet read the same store and update together with no prop drilling.
The distinction is practical. A cache can be dropped at any time and the app still works, just slower. Drop this and the user's wishlist is gone. The third argument exists for exactly that reason — it's the server snapshot, always empty, which is what makes the server HTML and the hydration render agree before the real value swaps in. Mirroring localStorage into useState inside an effect is the version of this that produces hydration mismatches; see fixing localStorage hydration mismatches in Next.js for why.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Twelve components fire twelve identical requests | Cache is inside the component, so there are twelve of them | Move it to module scope or a shared query client — above the components, not in them |
| The cache "works" but requests still stampede on first load | Caching the resolved value, not the promise | Store the in-flight promise so concurrent callers await the same one |
| One network blip breaks the feature until reload | A rejected promise got cached | Clear the cache entry in .catch so the next caller retries |
useMemo added, nothing got faster | Wrong layer — the cost was a request or a large subtree re-render | Measure first; useMemo fixes recomputation, not round trips |
| Test two sees test one's data | Module-scope cache persists across tests | Export a reset function and call it in beforeEach |
| Hydration mismatch after adding a client cache | Server render and first client render disagree | Use useSyncExternalStore with a server snapshot, don't set state in an effect |
Frequently asked questions
Do I still need useMemo and useCallback with the React Compiler?
Much less often. The compiler inserts memoization automatically for the render-layer cases, which are the ones people over-apply by hand. It does nothing for the data layer — a compiler cannot know that two components asking the same server question should share one request. That work stays manual, and it is where the wins in this codebase were.
Is a module-scope promise safe in React Server Components?
Not as a cache. Module scope on the server is per-process and shared between users, so caching per-user data there leaks it across requests. The pattern above is in a file marked "use client", where module scope means one browser tab. For server-side deduping within a single render, use cache from react, which is request-scoped by design.
When should I reach for React Query or SWR instead? When you need what they add: background refetching, stale-while-revalidate, retries, pagination, cross-component invalidation. This site needed one lookup per page load with no invalidation, so a nine-line module-scope promise did the job without a dependency. Writing your own becomes the wrong call the moment you start reimplementing their second feature.
Does caching ownership client-side create a security hole?
No, because it was never the authorization decision. The hook's own comment says so — it is a rendering convenience, and /api/download re-runs the real entitlement check on every request. Forcing the cached value in a browser console changes what the page draws and still returns 403 on the download.
Templates in this post
ASoc Mind, ASoc Momentum and ASoc Neuron are Next.js + Tailwind landing page templates, built with the same server-first default that keeps render-layer memoization rare.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
