React Compiler Removed Half My Re-render Bugs. The Other Half Aren't in Component Code
I expected the agent to reach for useMemo. It didn't, not once in six tries. The fixes it wrote were correct, which tells you where the hard part of re-render debugging has gone: with React Compiler on, the work is no longer deciding what to memoize. It's finding the file where a new reference gets created, and that file is usually not the component that keeps re-rendering.
React's 1.0 announcement for the compiler says new Expo apps on SDK 54 and later start with it enabled, and the post reports what it did in Meta's production apps: initial loads and cross-page navigations up to 12% faster, and some interactions more than 2.5 times faster. So the compiler is becoming the default. The useful question is what it leaves behind, and I wanted a measured answer, not a vibe.
Which re-render causes React Compiler fixes
I set up a scratch project with React 19.3.1, react-test-renderer and babel-plugin-react-compiler 1.0.0. I wrote eight small components, each reproducing one common re-render cause. I compiled the same source twice, once plain and once through the compiler. Each case got five updates that shouldn't have affected the target component, and I counted how many times it rendered (with a Profiler and an effect) and how many times it remounted.
This is a test renderer, not a device. It counts renders and doesn't time frames, so it tells you whether a cause survives the compiler, not how much it costs on a mid-range Android phone.
| Cause | No compiler | Compiler 1.0 | Where the fix lives |
|---|---|---|---|
| Parent state changes, child props are constants | 5 renders | 0 | Compiler handles it |
Inline style={{}} and onPress={() => {}} props | 5 renders | 0 | Compiler handles it |
| Component defined inside the parent's render | 5 renders, 5 remounts | 0 | Compiler handles it |
key={Math.random()} in render | 5 renders, 5 remounts | 0 | Compiler hides it (see below) |
| Context value has a fast field the consumer doesn't read | 5 consumer renders | 5 consumer renders, child shielded | Split the context |
Store selector returns items.filter(...) | 5 renders | 5 | Selector or equality check |
| Polling refetch returns the same payload as a new object | 5 renders | 5 | Structural sharing at the fetch |
| Key generated when data arrives | 5 renders, 5 remounts | 5 renders, 5 remounts | Stable IDs in the data |
Four causes disappeared, one was half fixed, and three didn't move. All three that survived share one property: the new reference was created outside render, in a subscription callback, a fetch handler or a state setter. The compiler only caches what it can see during render, so it has nothing to work with.
I also ran the reflexive fix. Wrapping the target in React.memo changed nothing for the store selector or the refetch: five renders out of five, with and without the compiler. memo compares props by reference, and the prop really was a new array or object each time. Making the selector return a shallow-compared result, or keeping the previous object when the payload hadn't changed, took both cases to zero.
The Math.random() row needs a footnote. The compiler cached the key along with the JSX, so the random value was computed once and the remounts stopped. That's an accident of an impure call being memoized, not a fix. The same bug moved into the data layer, where the key is minted when rows arrive, stays broken at five remounts out of five.
What the agent did with the survivors
Next I gave the three surviving cases to fresh Claude subagents, written as React Native screens with a header comment saying the compiler was on. Each case ran twice. One prompt just said the component "re-renders more often than it should. Fix it." The other made the agent first name which value changes identity and where that identity is created, and say whether the compiler already covers it, before proposing anything.
All six fixes were reasonable, and all six went to the data layer:
- Store selector: both runs changed the selector to return a count instead of a filtered array.
- Refetch: the bare prompt changed the child to take
nameinstead ofuser. The evidence-first prompt kept the prop and added structural sharing in the setter, returningprevwhen the payload was unchanged. - Context: both runs split the context into a theme context and a clock context.
I re-measured two of them in the harness. The count selector took the store case to zero. The name prop fix also went to zero, but only with the compiler on. Without it the parent still re-renders on every poll and drags the child with it. The agent had been told the compiler was on, so the fix was correct, but it only works because of that.
The evidence-first prompt didn't change the outcome here. Its explanations were better and it was the only run to keep a component's props intact, but the bare prompt got the same results. That's because I'd already done the hard part for it. Each snippet contained both the symptom and the cause in about twenty lines.
In a real app those are in different files. The screen that flickers renders a row, the row reads a selector from a store module, and the store is fed by a query hook in a third place. Point an agent at the screen and ask it to stop the flicker, and the cause isn't in its context. Memoization is the fix it can write without leaving the file.
Five of the six fixes also changed a contract: a prop renamed or retyped, a context export removed and replaced with two new ones. Two agents said outright that they hadn't checked who else imported the thing they'd changed. They couldn't have, because they'd been told to read one file. That's the review risk, and it doesn't show up in a diff that looks this tidy.
How to debug a re-render with React Compiler on
This is the order I'd follow now, for me or for an agent:
- Confirm the compiler covers the component. React DevTools marks compiled components with a Memo ✨ badge in the Components tree. No badge means the compiler skipped it, and you're debugging a lint problem, not a render problem. My first harness run hit exactly this: I reassigned a module-level variable inside seven components, and the compiler skipped all seven without a build error. It only showed up in the compiler's logger output.
- See the re-render before you reason about it. Turn on "Highlight updates when components render" in the Components panel's View Settings and reproduce the interaction. You want the component that flashes when it shouldn't.
- Record a profile and ask why. In the Profiler, select the commit and the component. If your version of the panel offers "Record why each component rendered while profiling", turn it on first, so the panel shows which props, state or hooks changed.
- Trace the changed value to where it's created. This is most of the work. Follow the prop back through the parent, the selector and the fetch until you reach the line that makes a new object, array or key. With the compiler on, that line is almost never in render.
- Hand the agent that file, plus the trace. "The
doneprop onDoneCountchanges identity on every store update; it comes from this selector" gets you a two-line fix. "The settings screen re-renders too much" gets you a guess. - Review the fix as an API change. Search for every consumer of anything whose shape changed, and re-profile to confirm the render count dropped.
The Profiler is easy to misread, too. My first measurement of the context case used a Profiler wrapped around the consumer inside a compiled parent. It reported zero re-renders with the compiler on. An effect counter in the consumer itself said five. The compiler had memoized the Profiler element, and its callback didn't fire for the context-driven re-render underneath it. I didn't dig into why. I switched to counting in effects and kept both counters side by side for the rest of the run. If a number looks too good, count it a second way.
Where this stops being true
The test renderer counts renders. It doesn't measure how long a render takes, and on a device a cheap extra render of a Text is often irrelevant. Five renders in this table could cost nothing, or could be the reason a FlatList drops frames. Measure frames on a real device before you spend a review cycle on it.
Eight hand-written cases aren't a survey of real codebases either. They're the common shapes, isolated. Real screens combine several, and the compiler's coverage depends on how much of your code follows the Rules of React. A codebase full of components the compiler skips will still need memoization in places, and the old advice still applies there.
Six agent runs is an anecdote, not an eval. What I'm confident about is the direction, not a rate: when the cause is in the agent's context it fixes the right thing, and when it isn't, it can't.
The context row is also more nuanced than the table makes it look. The compiler shielded the child, so the consumer's re-render was cheap: it re-ran, compared its inputs, and returned cached JSX. Splitting the context, as both agents did, is the textbook fix, but it's a bigger change than the problem may justify once the compiler is doing its part. Sometimes the right call is to leave it.
Further reading
- React Compiler v1.0 (opens in a new tab), React blog
- React Native DevTools (opens in a new tab), React Native docs
- Render optimizations (opens in a new tab), TanStack Query docs on structural sharing
- Adopting React Compiler Without Breaking Production, the rollout order for turning the compiler on
New posts by email
Sent when they're published. No spam, unsubscribe anytime.