●ENGINE — Rork Max is powered by Claude Code and Claude Opus 4.6, generating native Swift apps directly●CORE ML — Rork Max reaches on-device Core ML inference alongside HealthKit, HomeKit, NFC, and App Clips●SEED — Rork raised a $15M seed led by Left Lane Capital in April 2026, joined by Peak XV and a16z Speedrun●M&A — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talent●MARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026●GROWTH — The no-code AI platform market is projected to grow from $4.9B in 2024 to $24.8B by 2029●ENGINE — Rork Max is powered by Claude Code and Claude Opus 4.6, generating native Swift apps directly●CORE ML — Rork Max reaches on-device Core ML inference alongside HealthKit, HomeKit, NFC, and App Clips●SEED — Rork raised a $15M seed led by Left Lane Capital in April 2026, joined by Peak XV and a16z Speedrun●M&A — Rork acquired the app builder Paperline and says it will stay acquisitive to bring in engineering talent●MARKET — Gartner expects 75% of new applications to be built with low-code or no-code tools by the end of 2026●GROWTH — The no-code AI platform market is projected to grow from $4.9B in 2024 to $24.8B by 2029
Adding React Compiler to Expo Let Me Delete 41 Hand-Written memo Calls
I enabled React Compiler on Rork-generated React Native screens and measured the rerender counts with Profiler. Here is how I decided which memo and useCallback calls were safe to delete, how to find the components the compiler bailed out on, and how to catch regressions in CI.
Tapping the favorite button on a single thumbnail made the whole wallpaper grid flicker. I opened the Profiler and found that all 24 visible cells had rerendered. Exactly one cell had changed.
The cause was not mysterious. The onToggleFavorite handler that Rork had generated was recreated on every render, which rebuilt the entire renderItem for the FlatList.
So I did what everyone does. Wrap it in useCallback. Wrap the cell in React.memo. Stare at the dependency array. I have lost count of how many times I repeated that ritual across six apps. If I am honest, I have introduced more "this screen stopped updating" bugs through mistaken dependency arrays than I ever gained in milliseconds.
That fatigue is why I finally sat down with React Compiler.
What I Actually Tested
The subject is one of the wallpaper apps I run as a solo developer. Three React Native screens built on Expo — grid, detail, settings — originally generated by Rork. Between them they contained 63 hand-written React.memo, useCallback, and useMemo calls.
I wanted answers to three questions.
With React Compiler enabled, how much do rerender counts actually drop?
Are the hand-written memo calls safe to delete? Where does deleting them make things slower?
Can I find the components the compiler gave up on mechanically, rather than by reading every file?
The short version: rerenders on the main interaction dropped from 24 to 2, I deleted 41 of the 63 memo calls, and the remaining 22 each had a concrete reason to stay.
Passing react-compiler: true to babel-preset-expo lets the preset wire everything up. If you instead add the plugin to plugins by hand, you can end up applying it twice alongside the preset's React Native specific adjustments. I broke a build exactly this way once. Go through the preset.
Two preconditions matter.
Precondition 1: New Architecture must be on. The compiler technically runs on the old architecture, but the rendering behavior it assumes — automatic batching, the re-entrancy that Concurrent rendering brings — only lines up under New Architecture. With the old architecture still in place, the rerender count that should have fallen to 2 stalled at 5. My staged migration notes are in the New Architecture migration writeup.
Precondition 2: Clear the Metro cache. Without npx expo start --clear, you keep serving the pre-transform bundle. Most "I enabled it and nothing changed" reports are this. I covered Metro's caching quirks separately in the Fast Refresh troubleshooting notes.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A reproducible way to measure rerender counts before and after enabling React Compiler
✦Clear criteria for which hand-written memo and useCallback calls are safe to remove and which are not
✦How to find components the compiler silently bailed out on, using ESLint and build output rather than eyeballing
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Right after enabling it, the screen felt lighter. But the person who just did the work is the least reliable judge of whether it helped.
To count rerenders, I dropped in a dev-only Profiler wrapper.
// src/dev/RenderCounter.tsximport { Profiler, type ReactNode } from "react";type Counts = Record<string, { commits: number; totalMs: number }>;const counts: Counts = {};export function RenderCounter({ id, children }: { id: string; children: ReactNode }) { if (!__DEV__) return <>{children}</>; return ( <Profiler id={id} onRender={(profileId, phase, actualDuration) => { // phase: "mount" | "update" | "nested-update" if (phase === "mount") return; const entry = counts[profileId] ?? { commits: 0, totalMs: 0 }; entry.commits += 1; entry.totalMs += actualDuration; counts[profileId] = entry; }} > {children} </Profiler> );}export function dumpRenderCounts(label: string) { if (!__DEV__) return; const rows = Object.entries(counts) .sort((a, b) => b[1].commits - a[1].commits) .map(([id, v]) => `${id}\t${v.commits}\t${v.totalMs.toFixed(1)}ms`); console.log(`[render:${label}]\n${rows.join("\n")}`);}export function resetRenderCounts() { for (const key of Object.keys(counts)) delete counts[key];}
Discarding phase === "mount" is the important detail. Initial mounts are not what you are optimizing, and including them dilutes the before/after difference. I counted them at first and nearly concluded the compiler had barely moved the needle.
Fix the scenario. A vague interaction returns a vague number.
// Scenario: render grid, favorite the third cell, unfavorite itresetRenderCounts();await tapFavorite(2);await tapFavorite(2);dumpRenderCounts("favorite-toggle");
Five runs on the same device (iPhone 13 mini, iOS 26.4, dev build), median reported.
Measurement
Before (hand-written memo)
Before (all memo removed)
After (all memo removed)
WallpaperCell commits
2
48
2
GridScreen commits
4
4
2
Total commit time per tap
18.4ms
96.2ms
11.7ms
The middle column is the one to sit with. Stripping the memo calls without the compiler is decisively slower — those calls were not empty ritual, they were doing real work. The compiler simply does the same work slightly better. GridScreen fell from 4 to 2 because the compiler picked up an intermediate container I had never bothered to memoize.
Machines catching the places humans forget. That, more than raw speed, is the real return. Lower variance in performance matters more day to day than a better best case.
The 41 I Deleted and the 22 I Kept
Deleting memo calls wholesale is not safe. I used these criteria.
Safe to delete (41 sites)
Pure derived values in useMemo (items.filter(...), price * qty)
useCallback whose only purpose is passing a handler to a child
React.memo on components whose output is a function of props and state alone
The compiler generates equivalent or better memoization for all of these. Keeping them means memoizing twice and paying bundle size for nothing.
Worth keeping (22 sites)
First, values where referential identity is itself the contract.
// Kept: a config object that feeds a useEffect dependencyconst audioConfig = useMemo( () => ({ sampleRate: 44100, channels: 2 }), []);useEffect(() => { nativeAudio.configure(audioConfig); // a new reference reconfigures the native side}, [audioConfig]);
The compiler's memoization exists to stabilize rendering output, not to promise that a reference will be identical forever. Anywhere you have handed referential identity a job — reinitializing a native module, triggering an effect cleanup — keep the explicit useMemo.
Second, components the compiler bails out on. Remove the memo there and you are back to plain rerendering.
Third, the function passed to FlatList's renderItem. The compiler does stabilize it, but FlatList behavior is entangled with extraData, so I kept the explicit useCallback as a statement of intent. It costs nothing and it tells the next reader — including me in six months — that this stability is load-bearing.
Finding the Bailouts Without Reading Every File
This was the part that took real work. The compiler bails out silently. No error, no warning; the component simply passes through unoptimized.
Start with the lint rule.
npx expo install eslint-plugin-react-hooks@latest
// eslint.config.jsimport reactHooks from "eslint-plugin-react-hooks";export default [ { plugins: { "react-hooks": reactHooks }, rules: { // flags syntax the compiler refuses to analyze "react-hooks/react-compiler": "error", }, },];
That catches the classic bailout patterns — mutation during render, conditional hook calls — while you are still writing them. In my codebase it flagged 9 sites, 7 of which were the same mistake: sorting a props array in place during render. Some of it was Rork-generated, some of it was mine.
Lint only catches statically detectable patterns, though. To see how many components were actually compiled, look at the build output.
npx expo export --platform ios --dump-source-map 2>&1 \ | tee /tmp/expo-export.log# count the runtime hook the compiler injects into optimized functionsgrep -c "useMemoCache" dist/_expo/static/js/ios/*.js
useMemoCache is the runtime call the compiler inserts into every function it optimizes, so its occurrence count approximates compiler coverage. Mine read 118 before the fixes and 127 after the toSorted changes. That delta of 9 matched the 9 sites ESLint had flagged.
When the two numbers agreed, I finally trusted the measurement.
Two Gates in CI
Cleaning this up once means nothing; the next generated screen will undo it. I added exactly two gates.
# .github/workflows/react-compiler-guard.ymlname: react-compiler-guardon: [pull_request]jobs: guard: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci # Gate 1: do not add new bailout syntax - name: ESLint (react-compiler rule) run: npx eslint . --max-warnings=0 # Gate 2: compiler coverage must not shrink - name: Compiler coverage run: | npx expo export --platform ios >/dev/null 2>&1 COUNT=$(grep -oh "useMemoCache" dist/_expo/static/js/ios/*.js | wc -l | tr -d ' ') BASELINE=$(cat .compiler-baseline) echo "coverage: $COUNT (baseline: $BASELINE)" if [ "$COUNT" -lt "$BASELINE" ]; then echo "::error::coverage dropped from $BASELINE to $COUNT" exit 1 fi
.compiler-baseline holds the committed value and is updated only when I intentionally restructure something. The threshold is "must not shrink" rather than "must grow," because demanding growth turns every trivial refactor red. Stop regressions. That has been enough.
Code review changed. Nobody writes "you forgot a memo here" anymore. What remains is "who guarantees this reference is stable?" — a far better question, and one that lives closer to design.
Some things did not change. List virtualization quality, image decode cost, native layout. React Compiler touches JavaScript rerendering and nothing else. If your scroll jank comes from decoding, the compiler will not move a single number. My grid's scroll FPS showed no meaningful difference before and after.
Point it at the right problem and it works quietly, indefinitely.
If you want to tighten the state layer alongside it, pairing this with Zustand v5 state management works well — selector-level subscriptions and compiler memoization compose cleanly.
Where to Start
Change nothing. Add the Profiler wrapper to one screen, run your heaviest interaction five times, and write down the commit count. Touch babel.config.js before you have that number and you will never know whether it helped.
Get the number. Then enable the compiler. Deleting memo calls can wait until after that.
I have not rolled this out to all six apps yet, so I am still learning as I go. I hope these notes are useful when you make your own call.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.