I have counted the useMemo calls in this app (seven) and the useState calls in one panel (eighteen). This time it is effects, which are the hook with the worst reputation and the one most likely to tell you what a codebase is actually doing.

Nakodo is 72,683 lines of TypeScript. 80 files carry "use client". 33 useEffect calls live in 23 of them, so 55 client components have no effect at all.

useEffect 33 calls in 23 files useLayoutEffect 0 useSyncExternalStore 4 useState 69 useTransition 14 useOptimistic 3 useDeferredValue 0 

Two numbers in that list say most of it. useLayoutEffect is zero, which is the right number unless you are measuring the DOM before paint, and useSyncExternalStore is four, because three clock components and a session hint subscribe to things properly rather than through an effect and a state.

What the 33 are for

Every effect, grouped by the external thing it is talking to:

window event listeners 7 4 beforeunload, 1 keydown, 1 keydown+pointerdown, 1 custom event IntersectionObserver 4 two for animations, two for a guided tour timers 4 a countdown, a rotating line, two tour steps network calls on mount 4 accept terms, and three "write me a draft" focus and scrolling 3 a wizard heading, a panel, a transcript analytics 2 identify, and one prompt-shown event history and the URL 2 dropping a query parameter, dropping stale state teardown only 2 bodies that are nothing but a cleanup a third-party widget 1 Turnstile's reset an animation 1 195 lines of leaves a polling loop 1 52 lines console.error 1 the Next.js error boundary template router.refresh 1 when a countdown reaches zero 

18 of the 33 return a cleanup function. 10 have an empty dependency array. Not one has a missing dependency array, and exactly two carry eslint-disable-next-line react-hooks/exhaustive-deps, both of them the same pattern: ask the model for a draft once, guarded by a ref, and do not ask again when the identity of the callback changes.

The React docs' position is that an effect is for synchronising with a system outside React. Counting that way, 24 of the 33 qualify. The other nine are analytics (2), network calls fired on mount (4), rewriting the URL (2) and console.error (1). They are effects because there is nowhere else in a React component to put "when this renders, do a thing once", which is a real gap and not a mistake, but they are the nine I would look at first if any of them ever misbehaved.

The four that are the same six lines

Four of our editors let you leave with unsaved changes, and each one stops you:

useEffect(() => { if (!dirty) return; const warn = (e: BeforeUnloadEvent) => e.preventDefault(); window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [dirty]); 

That is in brief/editor.tsx, outreach/replies/editor.tsx, outreach/details/editor.tsx and outreach/proposal/editor.tsx. Character for character identical apart from the guard, which is !saved in one, !dirty in another, and !dirty || writing in the other two, because two of the editors can be mid-draft when the model is still writing and there is nothing worth warning about yet.

Meanwhile our entire hooks directory is 68 lines: two files, a confirm dialog and an action wrapper. The thing we copied four times is not in it.

This is the honest shape of duplication in a small codebase. Nobody decided to copy it; the fourth editor was written next to the third, and six lines is under everybody's threshold for extraction. useUnsavedWarning(dirty) is one line in each call site and four lines in a file, and the only reason it does not exist is that the cost of the duplicate never arrived all at once.

It would also fix a thing I only noticed while counting. beforeunload covers closing the tab and typing a new address. It does not cover a client-side navigation, which is what a user actually does when they click the breadcrumb at the top of the editor. The warning protects against the rarer exit.

The two effects with no body

Two effects are a cleanup and nothing else:

useEffect( () => () => { clearTimeout(timer.current); request.current?.abort(); }, [], ); 

That one is in the place search field: typing cancels the request before it, so the refs exist anyway, and the effect's entire job is to make unmounting do the same thing the next keystroke would have done. The other is Turnstile's, which removes the widget from Cloudflare's registry when the sign-up form goes away.

I like these two. useEffect(() => () => cleanup, []) reads oddly the first time, but it is the shortest honest way to say "nothing happens on mount, something must happen on unmount", and the alternative is a lie about setup.

One effect is 195 lines

The longest effect in the app, by a factor of nearly four, is the hero's leaf animation: 45 leaves on a superellipse path with no keyframes, all of it inside one useEffect(..., []). The next longest is 52 lines, the polling loop that watches a campaign for changes.

The median effect is six lines. The distribution is: one of 195, one of 52, one of 26, and thirty under 20.

That is what a healthy effect profile looks like, I think. The long ones are the two places where this app genuinely drives an imperative system over time, an animation loop and a poller, and they are long because that work does not decompose. The short ones are short because the framework already owns the rest.

The leaf effect also does the one thing every animation effect should: it reads the user's motion preference as its first act and leaves without drawing anything if the answer is "reduce".

const svg = ref.current; if (!svg || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; 

The effect that writes to the database

The one effect I would defend hardest and like least is in the terms notice:

useEffect(() => { startTransition(() => acceptTermsAction()); }, []); 

Our terms notice has no Agree button, because carrying on is the acceptance. The consequence is that the thing recording the acceptance has to run when the notice is seen, and "when this component is on screen" is an effect. It is a network call, in a transition, in an effect, on mount, with no cleanup, and it writes a row.

In React's strict development mode that effect runs twice, which is exactly the scenario the double invocation is designed to expose. It is safe here because the action is idempotent on the server, which is the general answer: an effect that fires a request on mount is fine if the request can happen twice, and a bug if it cannot. Ours can.

What I am doing about it

  • useUnsavedWarning(dirty) in src/hooks, replacing four copies, and extended to catch client-side navigation out of the editor rather than only tab closes.
  • The two exhaustive-deps disables become a useOnce(fn) hook, because "ask the model for one draft, ever" is a thing we do in two places and will do in a third.
  • Nothing else. 33 effects in 72,683 lines is not a problem to solve, and the nine that are not synchronising with anything are nine places where React does not offer a better tool rather than nine mistakes.

If you want to run this census on your own codebase, the useful part is not the count. It is the column that says what each effect is talking to. Ours, read that way, is mostly a list of browser APIs that predate React, which is the correct answer, plus four copies of six lines, which is the one the counting was worth doing for.