Scaling React in 2026: 7 Decisions That Mattered, and Why None of Them Were useMemo

Scaling a React app in 2026 had almost nothing to do with re-renders. The React Compiler deleted the hand-memoization busywork; the decisions that actually scaled were about where state lives, how data is fetched, and how much JavaScript you ship.
The biggest thing scaling a React app in 2026 taught me: the decisions that made it scale had almost nothing to do with re-renders. I spent my early years sprinkling useMemo, useCallback, and React.memo like seasoning, chasing render counts in the Profiler. Then the React Compiler shipped stable S1S2, started auto-memoizing at build time, and quietly deleted most of that work: one report measured 20–60% fewer re-renders for zero code changes S3. What the compiler doesn't touch is the stuff that actually decides whether an app scales: where state lives, how data is fetched, and how much JavaScript you ship. It explicitly won't fix your data-fetching waterfalls or your architecture S2. So here are the 7 decisions that mattered, and why none of them were useMemo.
Step 1: Did you turn the compiler on and stop hand-memoizing?
The first decision is to delete the busywork. React Compiler 1.0 reached stable status in late 2025 S2, and it inserts memoization per reactive scope at build time, often more precisely than you'd write by hand S1.
If you're on Next.js, switching it on is a one-line config change S4:
// next.config.js
const nextConfig = {
reactCompiler: true,
}
module.exports = nextConfigOnce it's on, the code you used to write disappears:
// Before: manual memoization everywhere
const filtered = useMemo(() => items.filter((i) => i.active), [items])
const onPick = useCallback((id) => setSelected(id), [])
// After: the compiler handles it
const filtered = items.filter((i) => i.active)
const onPick = (id) => setSelected(id)The default that didn't work was wrapping everything by hand. It was constant cognitive overhead, and almost nobody actually profiled whether it helped S2. useMemo and useCallback aren't dead. They're escape hatches now, for precise control like a value that feeds a useEffect dependency, not a daily reflex S1S2.
One caution: the compiler bails out silently on components that break the Rules of React, and you won't notice unless the lint rule is on S2. Keep it on.

Step 2: Is your state colocated, or living at the top of the tree?
Here's what the compiler can't do for you: it can't decide which components subscribe to a piece of state. That's an architecture choice, and it's where the real re-render cost lives.
Global, rapidly-changing state is one of the leading causes of slow React apps S6. Every update to a top-level store or provider re-renders everything wired into it.
The fix is colocation: keep state as close as possible to where it's used S6.
// State lives in the field that owns it, not in the parent form
function NameField() {
const [value, setValue] = useState("")
return <input value={value} onChange={(e) => setValue(e.target.value)} />
}The default that didn't work was lifting all state to a top-level provider "just in case." Colocation is a performance optimization and a maintenance one S6; reach for context or a store only for state that's genuinely shared across the tree.
Step 3: Are you treating server data as state?
This was the single most expensive mental model I had to unlearn. Server data is not state. It's a cache of state, and conflating the two is a mistake S7.
UI state (is the modal open?) and server-cache state (the user's data) have completely different problems: server data goes stale, needs refetching, deduping, and background updates S7. Managing it with useState and a hand-rolled useEffect means reimplementing a cache badly.
// Don't hand-roll server state
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then(setData)
}, [url])
// Let a cache layer own it: staleness, dedup, refetch handled
const { data } = useQuery({ queryKey: ["user", id], queryFn: fetchUser })Keep useState for UI state; put server data in a dedicated layer (TanStack Query, or the framework's server cache). The two buckets need different tools S7.
Step 4: Did you kill the data-fetching waterfall?
This is the one the compiler will never save you from S2, and it's usually the biggest scaling bottleneck in the whole app.
A waterfall happens when components each fetch their own data and render sequentially: every nested component waits for its parent's request before starting its own S8. The network tab shows a staircase instead of a stack.
There are two fixes, and both come down to decoupling data fetching from components S11. Fetch in parallel, or hoist the fetch above the components that need it and read from a shared cache:
// Waterfall: each await blocks the next
const user = await getUser()
const orders = await getOrders() // waits for user, for no reason
// Parallel: both start at once
const [user, orders] = await Promise.all([getUser(), getOrders()])If you already use TanStack Query, you can run the fetches high in the tree and let each component read the result from the QueryClient cache instead of triggering its own request S8. The default that didn't work was a useEffect fetch in every component, a guaranteed staircase.

Step 5: Did you move work to the server?
Server Components changed where the work happens. A server component can read your data layer directly without you building an API for it, and any heavy libraries it uses cost zero client bundle S10.
There's a subtler win too: a server-side waterfall is often better than a client-side one, because on the server you control the network S11. The link between your render server and your database is faster, closer, and more reliable than a user's phone on hotel Wi-Fi S11.
The default that didn't work was shipping data-fetching code, parsing libraries, and the fetch round-trip itself down to the client. Push static and data-heavy, non-interactive work to the server; keep client components for the parts that are actually interactive.
One honest caveat: RSC isn't a magic wand. If you await each query sequentially on the server, you've just moved the waterfall, not removed it S11.
Step 6: Are you shipping the whole app on the first load?
Bundle size is a scaling tax everyone pays and few measure. As the app grows, especially with large third-party libraries, the single bundle balloons and first load slows.
Code-split at route boundaries first; it's the biggest win for the least effort S12S13. Then lazy-load heavy components that only render on a specific interaction S13:
import { lazy, Suspense } from "react"
const Dashboard = lazy(() => import("./routes/Dashboard"))
;<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>The default that didn't work came in two flavors: one giant bundle, or (once people discover lazy) over-splitting every tiny component, which just trades load time for flicker and new waterfalls S13. Split by route and by heavy, offscreen features. Don't split your buttons.
Step 7: Are you measuring, or guessing?
The compiler removes a whole class of problems, but the biggest mistake teams make after enabling it is assuming performance is now "solved" S2. It isn't.
Profile before you optimize S5. Use the React DevTools Profiler for re-renders and the network and INP numbers for what the user actually feels S2. Your machine is faster than your users', so measure on a production build and a real device S5.
And remember what "scalable" actually means: it's as much about the codebase as the runtime. Feature-based module boundaries and typed API contracts are what let the team keep shipping as the app grows, the part no Profiler will show you. The default that didn't work was optimizing on vibes.
What I reached for vs. what actually scaled
| The instinct | What it cost | The decision that scaled | Where the leverage was |
|---|---|---|---|
useMemo on everything | Cognitive overhead, unmeasured | Turn the compiler on S1S2 | Build-time auto-memoization |
| State at the top of the tree | Whole-tree re-renders | Colocate state S6 | Smaller re-render blast radius |
Server data in useState | Stale data, manual refetch | A cache layer S7 | Right tool for server cache |
| Per-component fetching | Sequential waterfalls | Hoist / parallelize S8S11 | Network, not render |
| Libraries on the client | Bloated bundle | Server Components S10 | Zero-cost server work |
| One big bundle | Slow first load | Route-level code-splitting S13 | Ship less JS |
| Optimizing on vibes | Wasted effort | Measure first S5 | Data-driven fixes |
When this playbook applies, and when it's overkill
Reach for these decisions when:
- The app is genuinely growing: more routes, more data, more people touching the code.
- You can feel the pain: slow first load, janky interactions, a network tab full of staircases.
- You're on React 19+ or a modern framework where the compiler and RSC are available.
Skip the ceremony when:
- It's a small static SPA, a prototype, or a three-screen landing page. Don't bolt on a cache layer, RSC, and aggressive splitting for an app that doesn't have the problems they solve.
- You haven't tried colocation yet, so don't reach for a global store first S6.
- Your codebase breaks the Rules of React and you haven't enabled the compiler's lint rule: turning the compiler on blind will silently skip those components S2.
FAQ
Do I still need useMemo and useCallback in 2026?
Rarely. With the compiler on, they're escape hatches for precise control, like a value that feeds a useEffect dependency, not a default tool you reach for every day.
Will the React Compiler fix my slow app?
It cuts unnecessary re-renders, but it does not fix data-fetching waterfalls or architectural bottlenecks. Those are the things that actually decide whether an app scales.
How do I turn the compiler on?
In Next.js it's a one-line config flag. In other setups you add the compiler's Babel plugin and keep its lint rule on so you notice when a component bails out.
Should I delete all my existing memoization?
No. Leave it in place initially and remove it in a separate, tested cleanup pass, since removing it can change the compiler's output.
Colocation or a state-management library?
Colocate first. Use context or a store only for state that's truly shared across the tree, not as a default home for everything.
Where should server data live?
In a cache layer like TanStack Query or your framework's server cache, not in useState. Server data is a cache of state, not state itself.
What's the most common performance bug at scale?
Data-fetching waterfalls caused by each component fetching its own data and rendering in sequence. The network tab shows a staircase instead of a stack.
Do I have to use Server Components?
No, but they remove client bundle weight and move fetching closer to your data, on a network you control rather than a user's phone on hotel Wi-Fi.
Where do I start with code-splitting?
Routes: the biggest win for the least effort. Then lazy-load heavy, offscreen features. Avoid over-splitting small components, which just trades load time for flicker.
How do I know what to optimize?
Measure on a production build and a real device before you change any code. Your machine is faster than your users', so optimizing on vibes wastes effort.
Sources
- S1React Compiler (React docs)
- S2Stop Writing useMemo and useCallback: A Migration Guide to React Compiler 1.0
- S3React Compiler Deep Dive: How Automatic Memoization Eliminates 90% of Performance Optimization Work
- S4React Compiler: Drop useMemo and useCallback, Let the Compiler Handle It
- S5useMemo (React docs)
- S6State Colocation will make your React app faster, Kent C. Dodds
- S7Application State Management with React, Kent C. Dodds
- S8Fetch Waterfall in React, Sentry
- S9Performance and Request Waterfalls, TanStack Query Docs
- S10Server Components (React docs)
- S11The Big "Server Waterfall Problem" with RSCs, Kent C. Dodds
- S12lazy (React docs)
- S13Code splitting with React.lazy and Suspense, web.dev
Written by
Syed Moinuddin
Full Stack Engineer writing about AI tooling, agentic systems, and the frontend/backend craft. Follow along for more deep dives on the tools changing how we ship software.

