Nakodo is a Next.js 16 app on React 19.2.8: 582 TypeScript files, 72,683 lines. Most advice about React performance is about memoisation, so I grepped the whole thing to see how much of it we actually use.
useMemo 7 occurrences, 2 files useCallback 2 occurrences, 2 files React.memo / memo() 0 useDeferredValue 0 useLayoutEffect 0 useImperativeHandle 0 For scale, the same grep over the same tree finds useEffect in 23 files, useState in 33, useTransition in 11 and useActionState in 11. So the app is not short of client state. It is short of memoisation, and the reason turns out to be structural rather than principled.
Why there is so little to memoise
226 of the files are .tsx. Eighty of them start with "use client", and 17 of those eighty are the shadcn primitives in src/components/ui. Everything else renders on the server, which includes almost every list in the product: the campaign list, the conversations screen, the creator page, all 76 public pages. A .map() over rows appears in 103 files, and in most of them it runs once, on a server, into HTML.
React.memo protects a child from a parent that re-renders. If the parent is a server component, there is no parent render to protect against, so there is nothing for the wrapper to do. That is the whole explanation for the zero.
One of the seven useMemo calls is in src/components/ui/chart.tsx, which is vendored shadcn code. We did not write it, and it is the only memo in the repo that is not in the file below.
The six that are ours
They are all in src/app/(app)/campaigns/[id]/results-view.tsx, 1,153 lines, the biggest client component in the app and the one screen where a long list genuinely lives in the browser: filters, sorting, selection and a search box that must not wait on a round trip. You cannot look at it without an account, which the server will tell you itself:
$ curl -sI https://nakodo.app/campaigns HTTP/2 307 location: /login?next=%2Fcampaigns How it works describes what that screen holds from the outside. From the inside it is six derivations in a chain:
const urlFilters = useMemo(() => parseFilters(searchParams), [searchParams]); // The search box is local so typing never waits on the URL; it is written // to the URL with the other filters. const [query, setQuery] = useState(urlFilters.q); const f = useMemo(() => ({ ...urlFilters, q: query }), [urlFilters, query]); then a pass that compares the rows the server just sent against the snapshot the screen started with, then:
const [optimistic, setOptimistic] = useOptimistic(shown, (state, change) => /* ... */); const counts = useMemo(() => viewCounts(optimistic), [optimistic]); const list = useMemo(() => applyFilters(optimistic, f, markets), [optimistic, f, markets]); The interesting one is shown, the memo in the middle, because its dependencies are [rows, snapshot] and neither of those changes when you type. The search box is local state, so a keystroke re-renders the whole 1,153-line component. What recomputes is one derivation out of five: shown is identical, so optimistic is identical, so counts is identical, and only list runs again because f changed.
That is what the memo buys. Not milliseconds in a profiler: a dependency chain where the base array of useOptimistic keeps its identity through renders that have nothing to do with it. The other two useOptimistic call sites in the app pass a boolean, in a settings toggle and in an operator form, and neither needs a memo, because a primitive has no identity to lose.
What is deliberately not memoised
Three lines further down the same component:
const visible = list.slice(0, limit); const chosen = list.filter((r) => selected.has(r.channelId)); const fitCount = optimistic.filter(isFit).length; Three derived values, all recomputed on every render, none of them wrapped. The rule that fell out of writing this file is that memoising a single pass over an array you are already holding costs more than it saves: you pay a dependency array, a cache slot and the chance of a stale value, to skip one loop that was going to run in the time it takes to allocate the comparison.
What is worth memoising is applyFilters, and it is worth it because of what it does rather than because of the loop:
return list .map((r, i) => ({ r, i, v: sortValue(r, f.sort) })) .sort((a, b) => { /* ... */ }) .map((x) => x.r); A filter, then a decorate, sort and undecorate: four array allocations and a comparison sort, with a stable tie-break on the server's original order. That is the one derivation in the screen that is not linear, and it is the one that is cached.
Both useCallback calls are for useEffect
Neither of the two is about a child component. They exist because the function is an effect dependency:
const ask = useCallback((p: Part) => { /* ... */ }, [campaignId, router]); useEffect(() => { if (part && asked.current !== part) ask(part); }, [part, ask]); Without the wrapper, ask is a new function on every render, the effect's dependency array changes on every render, and the effect fires on every render, which here means asking the server to write part of a campaign setup over and over. The second one, in the support panel, is useCallback(() => { /* thirteen setState calls */ }, []), used the same way.
That is a different tool from the one the performance posts describe. useCallback for a prop is an optimisation. useCallback for a dependency array is a correctness fix, and it is the only kind this codebase has needed. 161 inline arrow handlers are passed straight into elements across the app's client components, and because no child is wrapped in React.memo, a stable identity for any of them would buy exactly nothing.
The gap
The results screen caps the DOM at PAGE = 50 rows and pages in more on demand, which is why the unmemoised passes stay cheap: the list can be long, the render is not. But applyFilters runs over every row in the campaign on every keystroke, and useDeferredValue appears nowhere in these 582 files. If a campaign's list ever grows to the point where typing feels sticky, that is the hook to reach for, and the memo chain above is already shaped to make it a two-line change.
Seven memos, two callbacks, no React.memo. The audit's real finding is that moving list rendering to the server does not reduce the amount of memoisation you need by a bit. It removes the reason for most of it.
