How useRef Became the Escape Hatch That Fixes React's Closure Traps and Rerenders
What You'll Get from This Article
This is the final chapter of the "Death of React" column.
In the previous article, "After Two Years of Using Every React State Management Library, I Chose the Most Modern Signal Solution", I told you to switch to Signals. This one does the opposite — I know many people are constrained by team standards, historical baggage, and component library portability, and can't use a single line of Signal code.
No problem. This article guarantees:
- No changes to React's component model or state management paradigm:
useState,useEffect, and regular components work as usual; only swap a few Hooks/component entry points when needed. - No touching the React Compiler black box, no reliance on any compilation magic, no installing any Babel plugins.
- But you will get:
- Event callbacks that no longer step on stale closures (getting old state inside
useCallback). - Event callbacks that no longer need dependency arrays — no omissions, no reference rebuilds due to changing read values.
- Understandable, controllable over-rendering — no more tearing your hair out over sky-full rerenders.
- Ready-made KeepAlive — switch away and back, and forms, scroll positions, and layouts are all preserved.
- Convenient wrappers for async Effects, skipping the first run, and running only once (with boundaries for strict lifecycle scenarios).
- A Vue-style router with KeepAlive and global route guards.
- Event callbacks that no longer step on stale closures (getting old state inside
And the most important fulcrum for all of this is one built-in React API: useRef.
For those in a hurry, the old rule: Chapter 1 is a complete pathology report. If you just want the medicine, skip directly to §1.6 Putting useRef in the Reactive Family — the fulcrum starts there. If you don't even want the principles and just want the code, jump to §1.8 useLatestCallback, copy it, and run. You can always come back to read the parts that bash React; they're not going anywhere.
1. Closures + Dependency Arrays + Sky-Full Rerenders: React's "Ancestral Mountain of Shit"
1.1 Root Cause: You Just Want to "Read It", React Forces You to "Subscribe to It"
The first time most people get disgusted is with useCallback.
You define a function, like onChange, which internally needs to use a bunch of state. Note — you just want to read them, not listen to them:
const [keyword, setKeyword] = useState('')
const [list, setList] = useState<Item[]>([])
// I just want to 'read' the current keyword and list on click
const handleSubmit = useCallback(() => {
submit(keyword, list)
}, [keyword, list]) // ← React: No, you must offer them up
React's core logic is just this rigid: whatever your function uses, the dependency array must list it, otherwise you get the "snapshot" from the previous render cycle — the classic closure trap.
1.2 The Opposition: "Just Add Them Manually, ESLint Auto-Fixes It Anyway"
Every time I complain about this, someone always jumps out:
"What's the big deal about writing a couple more dependencies? Besides,
eslint-plugin-react-hookscan auto-complete the deps for you."
Sounds reasonable. In reality? In reality, your code slowly collapses into an uncontrollable mountain of shit.
Because these useCallbacks mostly serve component interactions — onChange, onSubmit, onSelect. They get passed as props to child components. So the problem arises:
A value you "just wanted to read" changes, useCallback rebuilds a new function reference; this new reference is passed to a child component, piercing the child's memo, causing the child and its entire subtree to re-render.
You think you're doing performance optimization, but you're actually laying landmines. Once the scale grows, layered with Context and deep nesting, one day you open the console to see sky-full rerender logs and have no idea which insignificant dependency lit the fuse behind the scenes.
To judge whether a React project will rot, don't look at the architecture diagram; just count the average length of the useCallback dependency arrays. If it's over three, the mines are already laid, just waiting for someone to step on them.
1.3 What Exactly Does a Single setState Re-render?
The re-render triggered by setState starts from the component holding that state and recursively descends to all its descendant components — it doesn't alarm ancestors upwards, nor does it laterally affect siblings.
Official "Render and Commit" text: "For subsequent renders, React will call the function component whose state update triggered the render. This process is recursive..." — https://react.dev/learn/render-and-commit
Translation: In every subsequent render, React calls the function component whose own state update triggered this render. This process is recursive: if the updated component returns another component, it renders that one next, layer by layer, until there are no more nested components.
However, your "sky-full rerender" pain isn't an illusion. Because within the state holder's subtree, by default, all descendants' render functions will re-execute, regardless of whether they use that state (see PlainChild: 1 → 2 → 3).
And in real projects, state is often placed at very high levels (the App root, top-level Context Provider) — at this point, "subtree" ≈ "entire app", and the experience is indistinguishable from a "full re-render". Layered on top of the "inline callback piercing memo" described in 1.2 (see MemoBadCb: 1→2→3), the re-render scope spreads like ink.
- "Re-executing the render function" ≠ "Re-operating the DOM". All descendants' renders run (this is the real CPU cost), but only truly changed DOM nodes enter the commit phase.
- The popular saying "re-renders only when props change" is itself imprecise. The default is "parent re-renders → child re-renders", regardless of whether props changed; the shallow comparison of props (
Object.isone by one) only serves as the "should skip?" criterion when wrapped inmemo.
1.4 React's Official Stance: Avoid memo If You Can
So what does the official stance say about this chain reaction of "inline callbacks piercing memo"? The official stance says: Avoid memo/useMemo/useCallback if you can, unless you encounter a performance problem. I didn't make this up; it's the original text from the three API pages:
"You should only rely on
useCallbackas a performance optimization. If your code doesn't work without it, find the underlying problem and fix it first." —— https://react.dev/reference/react/useCallbackTranslation: You should only rely on useCallback as a performance optimization; if your code doesn't work correctly without it, find the underlying root problem and fix it first.
(The
useMemoandmemopages use the same phrasing, just swapping the name.)
This sentence is treated as a golden rule by countless people; I've heard it too many times. Every time I hear it, I want to laugh:
"Have you people never written code? Never inherited someone else's code?"
Even if you wrote it yourself, once the scale grows, by the time you "encounter a performance problem" and go back to troubleshoot — you're facing sky-full rerender logs, layers of Context, dozens of components. Guess how long it takes to pinpoint which missing memo is the culprit? The official line sounds breezy, as if performance problems are "easily troubleshooted after the fact." React's system is already hard to use; missing one memo causes the entire subtree to recursively re-render. How do you easily troubleshoot that?
Bluntly, this is shifting complexity onto the developer: no optimization by default, and you carry the blame when things go wrong.
An objective note: the official docs now have a notice at the top of these pages — recommending React Compiler for automatic memoization, so you "write fewer manual optimizations." That is, the official team itself admits that handwriting this stuff is annoying; it's just that the antidote they offer is another black box (see next section).
1.5 React Compiler: From Applause to Deleting the Repo and Fleeing
After many years, React finally served up React Compiler. I was initially applauding wildly, and even wrote an article praising it: "Four Years!! React, Do You Know How I Spent These Four Years?"
Now I just want to say "Take it away!! I don't want to taste this anymore."
What it does, officially termed automatic memoization:
"React Compiler automatically optimizes your React application by handling memoization for you, eliminating the need for manual
useMemo,useCallback, andReact.memo." —— https://react.dev/learn/react-compilerTranslation: React Compiler handles memoization for you, automatically optimizing your React application, eliminating the need for you to manually write useMemo, useCallback, and React.memo.
The principle is compile-time analysis of your component's data flow, allocating a fixed-length cache array for memoizable values based on "reactive scope" (the _c(size) / $[0] in the output). On the first render, it fills with the sentinel Symbol.for("react.memo_cache_sentinel") guaranteeing a miss; subsequently, if dependencies haven't changed, it reuses the cache. Source code here: react-compiler-runtime/src/index.ts
Sounds beautiful. But I quickly felt something was off.
First, it produced bugs in my hands. I used @preact/signals + React Compiler, and the reactivity went completely haywire. I spent over an hour troubleshooting, and finally, turning off React Compiler fixed it.
Later, I used AI to check GitHub issues and confirmed it: these two are innately incompatible; you cannot have both signals-react-transform and babel-plugin-react-compiler in the same file (preactjs/signals#652).
An unofficial Signal solution runs perfectly fine, but crashes when meeting the official compiler — so what's the choice? Decisively delete React Compiler, flee the mountain of shit, and return to the comfort zone.
Second, it claims "not to be a black box," but that sliver of transparency is basically useless in real engineering.
It does have a Playground to view compiled output, ESLint rules, and React DevTools marks optimized components with a ✨ badge.
But in real engineering, you won't paste every component into the Playground one by one to see what it compiled into just to troubleshoot a weird bug. When a problem arises, you face a blob of _c() cache arrays and the metaphysics of "why didn't this update / why did that update extra times." The higher its degree of "automatic," the weaker your control over the rendering process.
Third, and most ironic. React once championed "rejecting magic" — hence no two-way binding, no syntactic sugar, making you do everything manually. Now it serves up a fully automatic black-box compiler. And moreover —
React Compiler is being rewritten in Rust. This is something that has already happened and been merged: PR facebook/react#36173 "[compiler] Port React Compiler to Rust" was merged into the main repository on 2026-06-09. According to the update note from React team member rickhanlonii in the PR, it is already in production use at Meta — producing output identical to the TS version for 99.9% of Meta's codebase, with an incidental ~25% performance boost (the PR description body itself still marks it as experimental / WIP).
The most absurd part is the original words of PR author Joe Savona: "The architecture was heavily guided by humans (me) but majority coded by AI. ... I used Claude for the initial work that got from zero to OSS tests being green."
Translation: The architecture was mainly led by a human (me), but the vast majority of the code was written by AI... For the initial work that went from zero to making the open-source tests green, I used Claude.
That's right, the initial rewrite of the React compiler was mainly written by AI (Claude, specifically). (Here we need to separate two things and not confuse timelines: The React Compiler itself has now been promoted to stable on react.dev — the official words are "now stable and has been tested extensively in production", no longer an experimental feature; whereas this Rust port is currently still marked experimental / WIP. As for "what exactly it is," the official positioning has never changed — "a light Babel plugin wrapper," a lightweight Babel plugin wrapping the core compiler.)
A framework that once rejected all magic now entrusts its lifeline to a black box "written by AI, compiled in Rust, whose runtime details ordinary developers can't quite understand." It seems this road will be walked all the way to darkness.
Don't even think about it. Effects, dependencies, closures, Signals... solve everything you can yourself. React is beyond saving; don't hold any expectations for future versions.
1.6 Putting useRef in the Reactive Family
To understand why useRef is the fulcrum for all subsequent operations, first place it within the "reactive primitives" family for a comparison.
React's useRef is the poorest member of the family: a box with a stable reference that doesn't trigger re-renders when changed, .current for read/write, zero reactivity, just a plain object. But precisely because "changing it doesn't trigger a render," it is the only escape hatch in React that can stably hold a mutable value.
Vue's ref is often mistaken for a "Proxy-based reactive." It's not. Looking at the vuejs/core source code: ref() returns a RefImpl instance, whose body is a plain object with get value() / set value() accessors, relying on track() in the getter and trigger() in the setter to collect dependencies.
The one that truly uses ES6 Proxy is reactive(); only when a ref holds an object does it internally convert it to reactive() via toReactive(). When holding a primitive type, there is no Proxy at all.
The reason Vue requires you to write .value is that standard JS cannot intercept the reading and reassignment of plain variables (let x = 0; x = 1 cannot be hijacked). It can only wrap the value in an object and use property getters/setters to intercept, thus access must go through .value. The official docs state it plainly:
"In standard JavaScript, there is no way to detect the access or mutation of plain variables... The
.valueproperty gives Vue the opportunity to detect when a ref has been accessed or mutated." —— https://vuejs.org/guide/essentials/reactivity-fundamentals.htmlTranslation: In standard JavaScript, there is no way to detect the reading or modification of plain variables... The
.valueproperty gives Vue the opportunity to detect when a ref has been accessed or mutated.
This perfectly illuminates the cost of React's setXxx: since primitive types can't be listened to, you are forced to manually call a setter and create new objects every time, which is especially disgusting when writing multi-level nested immutable updates — hence the community spawned libraries like Immer (produce + draft, mutable writing producing immutable results), Mutative (a faster Immer alternative), and valtio (Proxy-based mutable state) to clean up the mess.
As for the relationship between ref and Signal: ref is fundamentally a kind of signal, and Vue's official team directly endorses this —
The "Connection to Signals" section in Reactivity in Depth writes: "Fundamentally, signals are the same kind of reactivity primitive as Vue refs." (Original text)
Translation: Fundamentally, signals are the same kind of reactivity primitive as Vue refs.
But in terms of seniority, this type of "dependency-tracking value container" can be traced back to Knockout observables in 2010, a full decade before Vue 3's ref (2020). What popularized the term "signal" was Solid. The TC39 Signals proposal traces this lineage the same way, backed by framework authors from Angular, MobX, Preact, Solid, Svelte, and Vue.
ref and modern signals are homologous, parallel implementations of the same kind, both inheriting from the earlier observable tradition.
This whole detour is just to clarify one thing: useRef is the weakest member of this family in terms of reactive capability — so weak it's reduced to just "stably holding a value." useRef is so poor it only has a box — but precisely because it's too poor to have reactivity, React can't be bothered to manage it, making it the only escape hatch.
And as you'll see next, it's precisely this "doing nothing" that makes it the sole fulcrum for hacking React's rendering.
1.7 Hacking React's Rendering Basically Has Only This One Fulcrum
To stably hold a mutable value without triggering a render, the most direct card in React is useRef. There are two common approaches around it:
- Approach 1 (Heavy): Dump all data into a
ref, ensuring its changes trigger no re-renders, then take over reactivity yourself and manually manipulate the DOM. The optimization where Signals are passed directly into JSX skipping the VDOM is essentially the industrial-grade version of this idea. - Approach 2 (Moderate): Write a Proxy wrapper that automatically calls an empty
setStateto trigger an update when setting a value, while all other places rely on the stable value from thereffor rendering.
But both of these require changing your coding style. The protagonist of this article takes the third, and most restrained, path — since most problems stem from "the reference stability of useCallback," we only perform surgery on the single point of "callbacks," leaving all other coding styles unchanged.
The protagonist enters: useLatestCallback.
1.8 The Ultimate Solution: useLatestCallback
First, look at the source code, just these few lines (react-tool/packages/hooks/src/memo.ts):
/**
* A useCallback that always gets the latest value, no closure traps
*/
export function useLatestCallback<T extends (...args: any[]) => any>(fn: T) {
const latestFn = useLatestRef(fn)
return useCallback((...args: Parameters<T>) => {
return latestFn.current(...args)
}, [latestFn]) as T
}
The dependency useLatestRef is also just a few lines (ref.ts):
export function useLatestRef<T>(state: T) {
const stateRef = useRef(state)
// Uses useInsertionEffect instead of useEffect: it runs during the commit phase,
// before layout/passive effects, so the ref is already the latest value during the useLayoutEffect phase
useInsertionEffect(() => {
stateRef.current = state
}, [state])
return stateRef
}
Note the use of
useInsertionEffectinstead ofuseEffect: it runs during the commit phase, before layout and passive effects, so the ref already points to the most recently successfully committed function during theuseLayoutEffectphase.If replaced with a regular
useEffect, the layout phase would see one extra frame of the old value. It will not adopt a candidate function from a concurrent render that React might eventually discard — this is precisely why it's more stable than writing to a ref during the render phase.But the boundary must also be stated: React officially positions
useInsertionEffectas the style insertion timing for CSS-in-JS libraries; here it's used as a userland shim simulating the originaluseEventcommit semantics in React 18+, not its official primary use case. Calling this callback during the render phase will still read the logic from the previous commit, and render should remain pure anyway.
The principle explained in one sentence:
- Each render produces a new
fn; only after this render successfully commits is it stored intolatestFn.current; - Externally returns a forwarding function cached by
useCallback; its reference remains stable during normal mounting and won't rebuild due to business dependency changes; - The stable shell calls
latestFn.current(...), thus obtaining the logic from the most recent successful commit, without leaking aborted concurrent renders.
Thus you simultaneously get two things that have always been mutually exclusive: a stable reference during normal mounting (not piercing downstream memo) + a closure that follows the most recent commit (able to get the state corresponding to the current UI).
For ordinary event handlers, the usage is very similar to useCallback, but without writing a dependency array:
import { useLatestCallback } from 'hooks'
// ✅ Event callback needs no deps, always calls the logic from the most recent commit, reference is stable
const handleSubmit = useLatestCallback(() => {
submit(keyword, list)
})
// ❌ Compare with useCallback: miss one dependency and step on a closure; list them all and the reference keeps changing
const handleSubmit = useCallback(() => {
submit(keyword, list)
}, [keyword, list])
This is the entire secret to event callbacks simultaneously getting "stable reference + latest committed closure." You just replace event-purpose useCallback with useLatestCallback, even saving the dependency array.
This pattern has been solidified into my team's React standards: event handlers, timers, and external subscription callbacks use
useLatestCallback, passing no dependency array. When a function is used for rendering, or when the function identity itself needs to express an input snapshot,useCallback/useMemoare still used. The AI section later will cover this.
1.9 Wait, Doesn't the Official useEffectEvent Exist?
Those in the know will ask: Didn't React 19.2 graduate useEffectEvent, which does exactly the job of "reading the latest value with a stable reference"?
Yes, but it was officially neutered, with restrictions so large it can't be used as a general solution. A point-by-point comparison (all with official original text):
My useLatestCallback |
Official useEffectEvent |
|
|---|---|---|
| Reads latest props/state | ✅ | ✅ |
| Stable reference | ✅ Stable during normal mounting | ❌ Official explicitly says "identity intentionally changes on every render" |
| Can be passed to child components | ✅ | ❌ Official explicitly says "Do not... pass them to other components or Hooks" |
| Suitable as a regular event callback | ✅ | ❌ Only serves Effect logic |
"Effect Events can only be called from inside Effects or other Effect Events. Do not call them during rendering or pass them to other components or Hooks." —— https://react.dev/reference/react/useEffectEvent
Translation: Effect Events can only be called from inside Effects or other Effect Events; do not call them during rendering, and do not pass them to other components or Hooks.
Ironically, the original useEvent RFC (reactjs/rfcs#220) was designed precisely as a general version: stable reference, usable as an event handler, passable to child components — "A Hook to define an event handler with an always-stable function identity." This is exactly the form I wanted for useLatestCallback.
However, history must be accurate: this general RFC was later explicitly abandoned by the official team. PR facebook/react#25881 renamed the experimental API to
useEffectEvent, actively narrowing the problem scope to "non-reactive logic inside Effects"; stable references for regular event callbacks were left to other optimization solutions. So the currentuseEffectEventis not a failed landing of the generaluseEvent, but a deliberately narrowed, dedicated API that indeed cannot replace regular event handlers.
So the community has been implementing this thing themselves for years — ahooks' useMemoizedFn, usehooks-ts's useEventCallback, and my useLatestCallback all belong to the "stable shell forwarding latest logic" category. What we've done is essentially pick up the general useEvent direction that the official team abandoned.
1.10 How It Differs from ahooks' useMemoizedFn
Since they're the same kind of thing, let's compare. First, look at ahooks' implementation (alibaba/hooks):
const useMemoizedFn = <T extends noop>(fn: T) => {
const fnRef = useRef<T>(fn)
// Synchronously writes the latest fn into the ref during the render phase
fnRef.current = useMemo<T>(() => fn, [fn])
const memoizedFn = useRef<PickFunction<T>>()
if (!memoizedFn.current) {
memoizedFn.current = function (this, ...args) {
return fnRef.current.apply(this, args)
}
}
return memoizedFn.current
}
The idea is the same category as useLatestCallback: a stable shell that internally forwards to the fn inside a ref. The most critical difference is "when the latest fn is written into the ref":
ahooks useMemoizedFn |
My useLatestCallback |
|
|---|---|---|
| Timing of writing latest fn | Render phase (fnRef.current = useMemo(...)) |
Commit phase (useInsertionEffect inside useLatestRef, before layout/passive) |
| Used in event handlers (99% of scenarios) | ✅ Equivalent | ✅ Equivalent |
Called inside useLayoutEffect |
✅ Gets the value written in this render | ✅ Gets the latest commit value (insertion runs before layout) |
| Called during render phase | Can read this candidate value, but shouldn't be used this way | Still the value from the previous commit; derive directly or use useMemo for derivations |
| Concurrent render aborted | Ref may have been prematurely polluted by the candidate render | Ref not updated, continues pointing to the logic corresponding to the current UI |
Dynamic this |
Preserved using apply(this, args) |
Current arrow function implementation does not preserve (usually no impact for regular React handlers) |
Incidentally, a detail many people don't understand: that line fnRef.current = useMemo(() => fn, [fn]) in ahooks is not a performance optimization.
When the caller passes an inline function, fn is a new reference every time, so useMemo still recomputes and spits back fn as-is. Its main purpose is to patch React DevTools: when DevTools inspects a component, it does a shallow render with mock hooks;
If you directly write to a ref during render, it could pollute the real ref with a version that has closed over the mock update function, causing callbacks to malfunction the moment you select the component (ahooks#728). Mock useMemo returns the cached value left by the normal render, neatly sidestepping this pollution.
In contrast, useLatestCallback updates the ref in the commit's insertion effect, so it won't be prematurely polluted by DevTools' mock render or by aborted concurrent renders.
The specific misalignment is this: while the old UI A is still interactive, React might be rendering new UI B in the background; ahooks would have already changed the shared ref to B's candidate callback, so clicking A at this moment could run into B's closure. useLatestCallback waits until B actually commits before switching, so the ref always aligns with the committed UI on screen.
Both sides also have their boundaries: ahooks preserves dynamic
this, while the currentuseLatestCallback's arrow function does not;useLatestCallback's shell comes fromuseCallback, and per the official contract, the stable identity should be treated as a performance optimization, not a permanent guarantee of business correctness. Event handlers, layout effects, passive effects — these normal call sites all get the latest commit value; just don't call it during render.
1.11 Companion: Can't Read the New Value Immediately After setState?
The closure trap also has a classic variant: you call setCount, and on the very next line, immediately read count.
You still get the old value (because React's state is a "snapshot" within this render and won't change midway).
const [count, setCount] = useState(0)
const fn1 = () => {
setCount(count + 1)
fn2() // count is still the old value 0
}
const fn2 = () => console.log(count) // 0
I covered this in a previous article; use useGetState for emergency relief: setCount.getLatest() directly gets the latest value, no need to pass a callback, no need for useEffect to catch up (source: useGetState.ts).
import { useGetState } from 'hooks'
const [count, setCount] = useGetState(0)
const fn2 = () => console.log(setCount.getLatest()) // 1
At this point, the triple threat of closure traps (reading the latest value), dependency arrays (no need to write them), and over-rendering (stable references guarding memo) is all solved using native React APIs, with zero migration cost in coding style.
Interlude: StrictMode and "No Conditional Hooks" — React Turns Its Own Fragility Into Your Rules
Speaking of re-renders, we must insert a segment on StrictMode, because it lays bare React's twisted design philosophy.
Create a new React project (CRA, Next.js all wrap you in <StrictMode> by default), open the console — every console.log prints twice. Components render twice, useState/useMemo initializer functions run twice, useEffect becomes setup → cleanup → setup. Everyone's first reaction is: what the hell is this, debugging is full of double logs, who can stand this?
What Is It Actually Doing?
StrictMode is a development-only, completely stripped in production checker (Official docs), doing two core things:
Calling things that "should be pure functions" twice — component render,
useState/useMemo/useReducerinitializer/updater functions. Official words: "Your components will re-render an extra time to find bugs caused by impure rendering." Bluntly: React assumes your render is pure (same input → same output, zero side effects), so it secretly runs it twice; if the two results differ (youpushed to props in render, mutated an external variable, etc.), it's exposed.Running Effects as
setup → cleanup → setup— forcing you to write symmetric cleanup. The real motivation isn't "now" but "the future": React Working Group Discussion #19 "Adding Reusable State to StrictMode" states it plainly — for scenarios like Offscreen (later<Activity>), Fast Refresh where "components may be repeatedly mounted/unmounted but state should be preserved," "components may 'mount' and 'unmount' more than once." So it simulates an "unmount then remount" in dev ahead of time, catching those subscriptions and timers where you forgot to write cleanup.
In other words, what it detects is render purity and Effect symmetry, not the "hook stability" many people assume.
It's Stupid and Annoying — Even React Admits This
Double logs are indeed anti-human, and I don't need to say this; React itself admits it. Initially, React 18 directly swallowed all console.logs from the second render, leaving developers even more confused (logs mysteriously disappearing); so they changed it to "don't swallow, just dim the second round of logs in DevTools" (Working Group Discussion #96); but this "DevTools hijacking console" scheme brought new problems — when dimming, it forcibly converts the second round of logs to strings, so the same console.log looks different twice, and you can't expand objects in the console to compare field by field. A dev checker that can make the most basic console.log go through two rounds of scheme changes and still leave a sequela inherently shows how awkward this design is.
The more fundamental question is: Why is it necessary at all? Because React's core model is too fragile — render must be pure, Effects must be symmetric, otherwise concurrent rendering, Offscreen, Fast Refresh will all sprout weird bugs. Yet React has zero guarantees for these at the language level, so it can only rely on a brute-force checker that "runs your code twice to see if it breaks," plus an ESLint plugin as the discipline enforcer. It shifts the fragility of its own model into discipline you must obey.
Following the Same Root Cause, There's That Even Stupider Rule: Hooks Cannot Be Called Conditionally
The iron law "React cannot call Hooks conditionally" comes from the exact same source as StrictMode.
The rule (Rules of Hooks): "Don't call Hooks inside loops, conditions, nested functions..." Why? The official companion ESLint rule page says it bluntly: "React relies on the order in which hooks are called to correctly preserve state between renders."
Human language: React has no idea which state belongs to which useState; it relies entirely on "the order of invocation" to match them up. Each component's hooks are stored as a linked list ordered by call sequence — the 1st useState, the 2nd useEffect... all identified by positional index. The moment you do if (x) useState(), one render calls one fewer, and everything after it shifts position, causing state to cross-wire.
The root is: React re-runs the entire component function from top to bottom on every update, so it must have a way to "remember" the previous state across invocations.
It could have let you manually pass a key (like useState('count')), but that's too verbose; so it chose "auto-match by call order." This is essentially a binary choice: either manually assign keys (ugly), or positional indexing (fragile). React picked the latter, at the cost of never being able to call hooks conditionally, after early returns, or inside loops, plus an ESLint plugin on 24/7 surveillance.
The intuition that "manually assigning keys is also stupid" is correct — but the real problem is that other frameworks don't need to choose between these two stupid options at all, because they fundamentally don't re-execute components:
- Vue:
setup()runs only once;ref/reactiveare reactive bindings on variables, declare them wherever you want, conditions and loops are fine, no keys needed, no "call order" concept. - SolidJS: Components run only once (official words: "a Solid component is only run once"). Since they don't re-run, they don't carry React's "call order" baggage —
createSignal/createEffectcan be called conditionally, in loops, and signals can even be written outside components. - Svelte 5:
$stateis a compile-time rune,let count = $state(0), state is just a regular variable, mutate it like a regular variable, can appear anywhere a variable can, without the mental burden of "must be top-level, cannot be conditional."
All three have none of this problem, for the same reason: They don't re-run the component function on every update like React does.
The fragile foundation of "render must be pure + hooks rely on call order" is something React chose for itself — and then, to patch it up, came StrictMode's double-run physical, Rules of Hooks, and screenfuls of ESLint warnings. Others use reactivity and compilation to absorb complexity into the framework; React spreads it out and writes it as the discipline you must recite daily.
StrictMode can be turned off (delete <StrictMode> if you added it yourself, reactStrictMode: false in Next.js). But don't turn it off just for "clean logs" — the bugs it catches will indeed explode when you actually use <Activity> or concurrent features. What truly deserves interrogation is never StrictMode, but the core model that makes StrictMode necessary, so fragile it needs a daily physical.
2. KeepAlive: A Need So Basic It's Absurd, Yet Dragged On Until 2026
2.1 React 19.2's <Activity> Is Here, But It Will "Kill" Your Effects
React 19.2 (2025-10-01) finally shipped the official KeepAlive: <Activity mode="visible | hidden">. When hidden, it preserves the DOM and component internal state; switch back and it restores as-is. Sounds delightful.
But it has a fatal problem: when you switch to hidden, it executes the cleanup functions of all Effects in your components (the return value of useEffect), and re-runs them when switching back. And — it's not configurable. Official text:
"When an Activity boundary is hidden, React will visually hide its children using the
display: "none"CSS property. It will also destroy their Effects, cleaning up any active subscriptions. ... When the boundary becomes visible again, React will reveal the children with their previous state restored, and re-create their Effects." —— https://react.dev/reference/react/ActivityTranslation: When an Activity boundary is hidden, React visually hides its children using the
display: "none"CSS property; it will also destroy the Effects of these child components, cleaning up all still-active subscriptions. ... When the boundary becomes visible again, React will display the children back along with their previously preserved state, and re-create their Effects.
The official Troubleshooting section even says outright: "conceptually, you should think of 'hidden' Activities as being unmounted." — Conceptually, hidden equals unmounted.
<Activity>'s props are only two: children and mode ('visible' | 'hidden'). There is no switch for "keep Effects, don't clean up."
This directly conflicts with my requirement: in many business scenarios, I simply want to visually hide something — subscriptions shouldn't break, timers shouldn't stop, Effects shouldn't re-run. Yet Activity forcibly treats it as an unmount. Its design philosophy is "hidden = conceptual unmount = disconnect side effects to save resources," which isn't wrong in itself, but it gives you no choice. If you want "pure visual hiding," using Activity is using the wrong tool.
2.2 "Then Why Don't You Just Use display: none?"
Every time this comes up, someone always asks. Those asking this question have mostly never written much complex layout.
The pitfall of display: none is: elements hidden by it cannot get correct DOMRect dimension information. If you measure width and height in a component's effect, you'll get all 0s. When doing complex layouts (virtual lists, canvases, animations requiring measurement), this is a disaster — you can only write a bunch of hacks to work around it, making the code too dirty to look at.
The official team itself listed the limitations of display: none in the 19.2 blog post (hidden components still render/initialize, trigger lifecycles, can't get correct dimensions...), which is also the reason they ultimately had to create <Activity>. Yet such a basic need, dragged on until the end of 2025 for an official solution, is genuinely laughable.
2.3 Another Path: Suspense + throw Promise
Actually, no need to wait for the official one. Before Activity, I had long implemented KeepAlive using Suspense + throwing a Promise. Look at the source (react-tool/.../KeepAlive.tsx):
const Wrapper = memo<KeepAliveProps>(({ children, active }) => {
const resolveRef = useRef<Function | null>(null)
if (active) {
resolveRef.current?.()
resolveRef.current = null
}
else {
throw new Promise((resolve) => {
resolveRef.current = resolve
})
}
return children
})
export const KeepAlive = memo(({ uniqueKey: key, active, children }) => {
return (
<Suspense fallback={ null }>
<Wrapper active={ active }>{ children }</Wrapper>
</Suspense>
)
})
Principle:
- When
active === false,Wrapperthrows a Promise during render → caught by the nearestSuspense→ displaysfallback(herenull, so visually disappears); - When
active === true,resolvethat Promise →Suspenseresumes rendering → the subtree's state remains intact as before.
React's underlying mechanism indeed uses CSS (toggling display) to hide the suspended subtree while preserving state (React 18 Working Group Discussion #7 "Behavioral changes to Suspense in React 18" covered this hiding mechanism).
Its fundamental difference from Activity lies precisely in side effects:
| Suspense + throw Promise | Official <Activity> |
|
|---|---|---|
| Hiding method | display: none |
display: none |
| State preserved | ✅ | ✅ |
| Cleans up Effects when hidden | Only cleans layout effects (useLayoutEffect); regular useEffect untouched |
Destroys all Effects (including useEffect) |
That is — with the Suspense path, regular useEffects (subscriptions, timers) inside the hidden subtree continue to live and are not cleaned up.
This perfectly meets my need to "just visually hide, don't stop side effects"; the cost is you need to be careful not to leave background leaks yourself. Activity is the opposite: hide equals clear the field. Neither path is absolutely superior; the key is that Activity gives you no choice, while the Suspense approach lets you control it yourself.
Two more pitfalls to warn about (added after verification, so you don't step on them):
throw promiseis Suspense's internal implementation protocol, not an officially documented public API. React 19 officially recommends usinguse()to suspend, butuse()requires the Promise to come from a cached source; a homemade KeepAlive needs to do its own caching. It's fine as an advanced trick, but be aware if using it as a long-term cornerstone.- When an already-mounted subtree suspends again, it will flash the fallback by default, unless the update is wrapped in
startTransition/useDeferredValue.
I later added exit transition animations to this KeepAlive: when deactivating, it doesn't suspend immediately but retains an
exitingwindow, letting the exit animation finish before truly hiding (transition/onExited/direction, derived fromuseDelayedActive). Switching pages is no longer a "snap" disappearance but can slide or fade out.
2.4 KeepAlive in the Router Ecosystem: Still "Not Built-In" by 2026
Because there was no official KeepAlive before React 19.2, mainstream router libraries all do not have built-in page caching. I reached this conclusion last year and the year before; this year (2026-06), I specifically rechecked — it still holds, just the details need updating:
- react-router: v8 was released on 2026-06-17; as of this article's review (2026-07-16), the latest is 8.2.0, still without page component KeepAlive. It received issue #9350 "Support Offscreen" back in 2022, which was subsequently closed without implementation. It provides loader/action, navigation state, and View Transition capabilities, but does not handle preserving rendered route component instances.
- TanStack Router: It has "cache" in its vocabulary, but what it caches is data returned by loaders (with staleTime/gcTime), not rendered component instances. Component-level keep-alive is still absent.
- Third-party:
react-activation(the veteran hack version) is cooling down, with React 19 compatibility issues still hanging open;keepalive-for-reactis relatively active, and from v5 can optionally interface with Activity. But these are all solutions outside the router libraries.
The direction is clear: React finally (19.2) patched the <Activity> primitive, but mainstream router libraries still don't have built-in KeepAlive to this day; you still have to wrap a layer yourself if you want to use it. Do none of you build routers with caching?
For this, I wrote @jl-org/react-router, with an API leaning towards Vue Router:
import { createBrowserRouter, RouterProvider, Outlet } from '@jl-org/react-router'
const router = createBrowserRouter({
routes: [
{ path: '/', component: lazy(() => import('./views/home')) },
{
path: '/dashboard',
component: lazy(() => import('./views/dashboard')),
meta: { title: 'Dashboard', requiresAuth: true },
},
],
options: {
// ① Built-in LRU page cache: whitelist + limit, switch away and back, forms/scroll all preserved
cache: { limit: 5, include: ['/', '/dashboard'] },
// ② Vue-style global guards, route authentication handled in one place
beforeEach: async (to, _from, next) => {
if (to.meta?.requiresAuth && !getUser()) { next('/login'); return }
next()
},
afterEach: (to) => { document.title = to.meta?.title ?? 'App' },
},
})
It provides a trio rarely seen in the React ecosystem:
- Built-in LRU KeepAlive (cache.ts), with
include/excludewhitelist,cacheKey, and capacity limit, built on the Suspense KeepAlive above;router.clearCache()/router.deleteCache(matcher)can clear cache on demand; - Vue-style global guards
beforeEach/beforeResolve/afterEach(guard-manager.ts) + Koa-style middleware; - Router instance as global API:
router.navigate()/router.replace()/router.clearCache(), no need touseXxx()first to get a hook.
Something so basic and useful, I don't know why no one in the React ecosystem has done it. Do you all handle route authentication by "wrapping each route entry with a component check"? Truly anti-human design to the end. (Detailed comparison in README)
3. Effects: Convenient Wrappers for Async, Skipping First Run, and Running Only Once
Native useEffect has a few more old ailments: the callback can't be async, the first mount always executes (to skip it you have to manually write an isFirstRender ref), and wanting to run only once also requires manual gearing.
useCustomEffect centralizes these common controls into one entry point (lifecycle.ts):
useCustomEffect(
async () => {
const data = await fetchSomething()
// async setup returns cleanup after resolving
return () => cleanup(data)
},
[dep],
{
immediate: true, // false = don't execute on first mount (equivalent to useUpdateEffect)
once: false, // true = execute only once
effect: useEffect, // can also swap to useLayoutEffect
},
)
Compared to the native one, it adds: async callback (cleanup obtainable after Promise resolves) / skip first run (immediate: false) / run only once (once) / swappable underlying effect hook / internally uses useLatestRef to read the latest committed closure. It also spawns handy aliases like onMounted, useUpdateEffect.
This is not a cancellation or race-condition manager. Async effects still need to use
AbortControlleror sequence numbers to discard stale results themselves; cleanup is only obtainable after the Promise resolves, and a new effect might execute before the old cleanup. The current implementation also only usesinstanceof Promiseto identify native Promises, and attaches rejection handlers only at cleanup time, making it unsuitable for thenables, cross-realm Promises, or tasks requiring immediate failure handling.
immediate/oncealso use refs to block counts: in development, StrictMode will additionally replay Effects, soimmediate: falsemight execute on the second setup, andonce: truemight not re-setup after the replay cleanup. Thus it's suitable for general convenience logic; don't use it to carry subscriptions, connections, and resource locks that must strictly symmetrically align with the mount lifecycle.
There's also a companion useStable, specifically for treating "an object literal stuffed in the dependency array, causing the effect to repeatedly execute due to a new reference every time":
// Only use for "complex objects/arrays", don't use for primitives (Object.is is already efficient, wrapping it is pure overhead)
const stableOptions = useStable({ a: 1, b: 2 })
useEffect(() => { /* ... */ }, [stableOptions]) // Won't re-run if content hasn't changed
4. The AI Era: Feed This Spec to AI, Let It Write "Correct React" for You
Finally, something unavoidable in 2026: AI coding.
Having these hooks alone isn't enough — you need to make AI write according to this spec every time, instead of spitting back the official useCallback + hand-filled dependency array mountain of shit.
So I solidified the entire set of conventions into a Claude Code React Skill, placed in my dotfiles repo: github.com/beixiyo/dotfiles (my full personal config workflow) under .claude/skills/react/SKILL.md
It is strongly bound to the react-tool template project — the skill lists all the proprietary APIs from the packages/hooks, packages/comps, packages/utils workspace (I didn't publish to npm; it's a template project, downstream just copies it).
This skill is essentially react-tool's "engineering constitution," with the very first line being "Must read; before writing any React code, read the full text first". Core mandatory specs include:
- Event handlers, timers, and external subscription callbacks use
useLatestCallback, no dependency array passed; useStableonly for complex objects, strictly forbidden for primitives;- Derived values don't go through effects, events don't detour through effects, chained cascading
setStateforbidden; - Components uniformly
memo+ named exports, logic extracted intouseXxx.ts, TailwindCSS + Design Tokens; - Business code mandates Signals; component libraries (for portability) use native + proprietary hooks.
(In the skill, I deliberately delineated the scope of application: these useLatestCallback, business Signal conventions are rules set for projects that have already adopted this internal package set; when encountering external/open-source projects without these internal packages, it will first read the project's own configuration and code style, not forcibly apply them.)
The effect: when AI writes event callbacks, it defaults to avoiding stale closures and meaningless reference changes, while preserving native semantics in places like render functions and input snapshots where useCallback is genuinely needed. Specs as guardrails — by writing the applicability boundaries into the skill, AI won't mechanically apply one optimization to all functions.
Final Words
"Death of React" has reached this point; it's time to wrap up.
I was never here just to curse — JSX is epoch-making; componentization, declarative style, unidirectional data flow are React's legacy to the entire industry, and I acknowledge all of that. What I criticize are those anti-human details that are never fixed in time: closure traps, manual dependency arrays, sky-full rerenders, KeepAlive dragged out until 2025, useEffectEvent that only solves the Effect scenario, and a compiler black box walking further into darkness.
The official team plays dead, so we fill the holes ourselves. Fortunately —
These holes can be filled, one by one, using useRef plus a few dozen lines of clear, inspectable wrappers, tackling the most torturous parts.
You don't need to switch frameworks, nor introduce compilation transforms. Replace event-purpose useCallback with useLatestCallback, plug in KeepAlive and routing per scenario, then feed these boundaries together to AI — and then you can write React like a normal person.
The source code is all here, help yourself:
- Utility library / Component library / Hooks: github.com/beixiyo/react-tool
- Vue-style router (KeepAlive + global guards + middleware): github.com/beixiyo/react-router
- AI spec (React Skill): github.com/beixiyo/dotfiles
Four years on, React is still that React. But at least, we can choose to live a bit more comfortably.
I have, up to now, finally never written a native useCallback again — perhaps React is indeed dead.
---- End ----