CogniPrep runs React 19.2.7 on Next 16. React 19 shipped nearly two years ago now, so I counted which of it we actually use. Occurrences first, then the number of files, because the second number is the one that says whether a pattern is a habit:
useState 1195 occ 266 files useEffect 862 occ 257 files useRef 491 occ 145 files useMemo 320 occ 119 files useCallback 316 occ 91 files useReducer 298 occ 148 files useId 28 occ 13 files useTransition 13 occ 6 files useLayoutEffect 12 occ 6 files useActionState 4 occ 2 files useFormStatus 4 occ 2 files useDeferredValue 2 occ 1 file useOptimistic 0 useSyncExternalStore 0 createPortal 0 Four of the APIs React 19 is sold on account for ten occurrences between them, across three files.
useReducer is in more files than useMemo
That line surprised me more than the zeroes. 148 files reach for useReducer against 119 for useMemo and 91 for useCallback, and 146 of those 148 are practice test components.
It makes sense once you see what they are. A timed assessment is a state machine: an item is presented, an answer lands or a clock expires, a stage ends, the next one starts. Written with useState that is six setters called in an order that must not vary, and every bug report is "it got into a state where". Written with useReducer the transitions are a list you can read and a list you can test, which is why those components have tests at all.
The usual advice is to default to useState and reach for useReducer when state gets complicated. In a codebase where almost every page is a machine with a clock, the default inverted itself without anyone announcing it.
320 useMemo, 316 useCallback, and no compiler
The React Compiler exists specifically to make most of those unnecessary. We are not running it. Our experimental block in next.config.mjs has exactly one key in it, and that key is optimizePackageImports.
So the 636 memo calls are all hand-placed, and I have no measurement that says which of them earn their keep. That is the honest position: they are there because a reviewer thought a child would re-render, not because a profile said so. Of the two, useCallback is the one I would delete first, since 91 files using it against 266 using state suggests it is applied where a prop is passed down rather than where a memoised child consumes it.
Eleven forwardRef files on a version that does not need it
forwardRef appears 29 times in 11 files, and all 11 are the UI kit. React 19 passes ref as an ordinary prop to function components, so those wrappers could each lose a layer.
They are not broken and forwardRef is not removed, just deprecated for new code, so this is pure tidying. It is also a nice fossil record: the kit was generated from a component CLI that targeted React 18, and nothing has gone back through it since. 11 of our 16 kit files carry a pattern the runtime stopped needing.
The form hooks are in the recovery forms, not the main ones
All four uses of the React 19 form hooks are in the auth screens, split in an order I did not expect:
-
useActionStatein the forgot-password and reset-password forms -
useFormStatusin the login and sign-up forms
The first pair are on the React 19 path properly: a <form action={serverAction}>, state and pending threaded back by the hook.
The second pair are not. Login and sign-up are <form onSubmit={handleSubmit}> with preventDefault(), client-side field validation, and the action invoked by hand as an async function. Their submit buttons are separate components that read const { pending } = useFormStatus(). useFormStatus reports on the form action React itself is running, and these forms never hand React an action, so pending is false for the whole request. The button that renders {pending ? 'Signing in...' : 'Sign In'} and disabled={pending} is a busy state that cannot arrive.
Two different hooks, both correctly imported, and only one pair wired to the thing it observes. The lesson I take is that useFormStatus is not a loading flag, it is a subscription to a specific mechanism, and nothing in the type system will tell you the mechanism is absent. A plain useState(false) around the await would be honest code and is what those two forms want.
Seven Suspense boundaries, six of them behind the login
Hand-written <Suspense> appears seven times: a dashboard layout, two on the dashboard page, the interview and exercise index pages, a game runner and the reset-password form. Three more exist only inside comments explaining why the boundary next to them is placed where it is.
The landing page has none. It ships five.
See it
curl -s https://cogniprep.app/ | grep -o '<!--$?-->' | wc -l # 5 curl -s https://cogniprep.app/ | grep -o '\$RC(' | wc -l # 5 curl -s https://cogniprep.app/pricing | grep -o '<!--$?-->' | wc -l # 0 <!--$?--> is React's marker for a Suspense boundary that was still pending when the HTML was flushed, and $RC( is the call that swaps the real content in later in the same response. Five open, five closed, in one 477 KB document. cogniprep.app/pricing and a provider page both return zero of each.
All five come from next/dynamic. The landing page lazy-loads five heavy sections, each with a loading: fallback that reserves 600 pixels (400 for the last one), and dynamic() wraps each one in a boundary for you. So the streaming behaviour on our most important page is entirely generated, the fallbacks you can see in the HTML are the ones we wrote, and the boundaries holding them are not.
Two other things worth checking in that same HTML. The 5 boundaries all resolve server-side, so the content is in the response for a crawler, which is the bit I actually wanted to confirm. And:
curl -s https://cogniprep.app/login | grep -o '<form[^>]*>' | wc -l # 2 Both of those are the OAuth buttons. The email form is behind a "Continue with Email" toggle and is not in the server HTML at all, which is why the server render could not have told me anything about the pending state above.
Count hooks by file, not by occurrence, and read the zeroes as well as the big numbers. The zeroes are the version of React you are not using yet.
