Before diving into this article, I have to say that I haven't been a front-end developer for a very, very long time 😁 I just analyze architectures and enjoy listening to people who argue about what's best to use on the front end. So I might be wrong about some things. 🤷♂️🤷♂️🤷♂️ Please correct me if I'm wrong!
Recently, I came across a team that was rewriting a project from Vue to Ract, and their argument was that Vue runs slower. 😬 This made me wonder, why does Vue run slower? And it turned out that the Vue code had been generated but not optimized.
And while there were a lot of questions about the code generation itself in the Vue project, the team didn’t have any questions at all about the generated code in the React project because everything works well there. 🤔🤨 But I won’t go into that right now, I’d rather analyze everything in detail and describe it in another article.
I won't go into a detailed comparison either, since there's plenty of that online. I'll just list the three main popular aspects below. Some add a fourth about templates versus JSX. That one's taste, so I'll leave it alone.
| React | Vue | |
|---|---|---|
| Virtual DOM | yes | yes |
| Memoization | by hand, with memo, useMemo and useCallback
|
automatic, through reactivity |
| What re-renders | the component and its children, unless you stop it | only what read the state that changed |
Every row in that table is out of date or about to be. React now ships a compiler that writes the memoization for you, and Vue is finishing a compile mode that drops the virtual DOM for any component that opts in. The two of them started from opposite ends of the same old argument and ended up in roughly the same spot: a build step that works out what doesn't need to run.
That's why I think this is the first year the interesting React vs Vue question isn't a technical one. They aren't the same framework. There's a whole section below on where they still split, but the three talking points everyone reaches for are exactly the three that are dissolving.
React's Memoization Moved Into The Build
React's default is simple and a bit blunt. The memo docs say it in one line: "React normally re-renders a component whenever its parent re-renders." For years the fix was to tell React by hand what was safe to skip. Here's what that looks like in a small product list with a search box and rows you can pick:
ProductList.jsx (by hand, before the compiler)
import { memo, useCallback, useMemo, useState } from 'react'
const ProductRow = memo(function ProductRow({ product, onPick }) {
return <li onClick={() => onPick(product.id)}>{product.name}</li>
})
export function ProductList({ products }) {
const [query, setQuery] = useState('')
const [picked, setPicked] = useState(null)
const visible = useMemo(
() => products.filter((p) => p.name.includes(query)),
[products, query]
)
const onPick = useCallback((id) => setPicked(id), [])
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<p>Picked: {picked ?? 'nothing'}</p>
<ul>
{visible.map((p) => (
<ProductRow key={p.id} product={p} onPick={onPick} />
))}
</ul>
</>
)
}
There are three tools here and two of them only work as a pair. memo lets a row skip rendering when its props look the same as last time. useCallback keeps onPick the same function between renders. That part matters. A fresh function counts as a changed prop and quietly defeats the memo. Forget the useCallback and the memo still compiles, still passes review, still looks perfectly responsible in the diff. It just skips nothing. useMemo is the odd one out. It keeps the filter from running again when the only thing that changed is picked.
The React team was blunter about all this than I'd be, and in the React Labs post from February 2024 they wrote that "manual memoization is a compromise. It clutters up our code, is easy to get wrong, and requires extra work to keep up to date."
React Compiler 1.0 shipped in October 2025 and works with React 17 and up. For new code the release post recommends "relying on the compiler for memoization and using useMemo/useCallback where needed to achieve precise control." Expo has had it on by default since SDK 54. Next.js 16 made the reactCompiler option stable but left it off by default while the team gathers build performance data (they also warn that builds get slower with it on, since the compiler runs through Babel, and the Rust port that runs inside Turbopack only showed up in Next.js 16.3 in August 2026 as an experimental flag). So "React memoizes for you now" is true, with a footnote: on the most popular React framework, somebody still has to flip a switch first.
What matters for this comparison is the component with the compiler on, next to the Vue version of the same thing:
ProductList.jsx (with React Compiler)
import { useState } from 'react'
function ProductRow({ product, onPick }) {
return <li onClick={() => onPick(product.id)}>{product.name}</li>
}
export function ProductList({ products }) {
const [query, setQuery] = useState('')
const [picked, setPicked] = useState(null)
const visible = products.filter((p) => p.name.includes(query))
const onPick = (id) => setPicked(id)
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<p>Picked: {picked ?? 'nothing'}</p>
<ul>
{visible.map((p) => (
<ProductRow key={p.id} product={p} onPick={onPick} />
))}
</ul>
</>
)
}
ProductList.vue
<script setup>
import { computed, ref } from 'vue'
import ProductRow from './ProductRow.vue'
const props = defineProps(['products'])
const query = ref('')
const picked = ref(null)
const visible = computed(() =>
props.products.filter((p) => p.name.includes(query.value))
)
</script>
<template>
<input v-model="query" />
<p>Picked: {{ picked ?? 'nothing' }}</p>
<ul>
<ProductRow
v-for="p in visible"
:key="p.id"
:product="p"
@pick="picked = p.id"
/>
</ul>
</template>
The React version's just the first one with every wrapper deleted. The Vue version never had wrappers to delete.
The Compiler Writes Your useCallback, Not Always Your useMemo
I ran that plain React file through babel-plugin-react-compiler 1.0.0, because I wanted to see what it actually produces rather than trust the diagrams, and the answer is a lot of bookkeeping: ProductList gets 21 cache slots, ProductRow gets 6 of its own, and every one of them is a plain array entry checked by an if. Here's the part of ProductList that matters, trimmed down:
ProductList.jsx after babel-plugin-react-compiler 1.0.0 (trimmed)
export function ProductList(t0) {
const $ = _c(21);
const {
products
} = t0;
const [query, setQuery] = useState("");
const [picked, setPicked] = useState(null);
// ...
if ($[0] !== picked || $[1] !== products || $[2] !== query) {
// ...
const visible = products.filter(t4);
let t5;
if ($[8] === Symbol.for("react.memo_cache_sentinel")) {
t5 = id => setPicked(id);
$[8] = t5;
} else {
t5 = $[8];
}
const onPick = t5;
// ...
}
// ...
}
That Symbol.for("react.memo_cache_sentinel") check is the compiler's version of useCallback(fn, []). The first render stores the function in slot 8 and every render after that reads it back. Nobody wrote a dependency array.
The filter's where it gets interesting. My hand-written useMemo was keyed on products and query. The compiler put products.filter(...) inside a block that's also keyed on picked. So picking a row runs the filter again. With fifty products nobody will ever notice. If the filter were genuinely slow, this is the case the release post means when it says to keep useMemo for "precise control", because the compiler caches what flows into the output and its idea of a sensible cache boundary (one big block keyed on everything the JSX reads, as far as I can tell from this output) isn't always the one I'd have drawn by hand.
Vue Got The Same Result From Reactivity
None of this was ever a question in the Vue version. The reason is how Vue decides what to run. Its docs describe it plainly: "each component instance creates a reactive effect to render and update the DOM." The render reads query, picked and visible, Vue records those reads, and only a change to one of them re-runs this render. computed caches its value and only re-runs the filter when products or query changes, so picking a row doesn't re-filter and I never had to say so.
Children follow a matching rule. Vue's performance guide says "a child component only updates when at least one of its received props has changed."
Here's the detail I didn't know until I read the compiled output. In the normal virtual DOM build the inline @pick handler inside the v-for becomes a brand-new function on every render. That's exactly what useCallback exists to prevent in React. Vue doesn't care. The runtime function that decides whether a child updates is called shouldUpdateComponent, and in it a changed prop only counts if it isn't a declared event listener, so as long as ProductRow declares pick in defineEmits, a brand-new handler on every render doesn't make a single row update. React asked me to keep the function stable. Vue just stopped comparing it.
Vue isn't completely free either. "Never needed memoization" oversells it a little. The same performance guide has a section on props stability: pass activeId to every item in a list and every item updates when it changes, pass active (a boolean worked out in the parent) and only the items whose value actually flipped do. That's the same class of problem React's memo solves, except in Vue you fix it by shaping props instead of wrapping components.
Vue even has its own dependency array. v-memo, which arrived in 3.2, skips a whole subtree unless the values in its array change. The docs say it's "provided solely for micro optimizations in performance-critical scenarios and should be rarely needed." Rarely needed is the honest version of never.
Vue Is Compiling Its Virtual DOM Away
Vue's virtual DOM already isn't the purely runtime kind. The template compiler leaves hints for the runtime (patch flags that say which part of a node can change, cached static content, flattened blocks so the diff skips whatever can't change), and the Vue docs call this a "Compiler-Informed Virtual DOM" and contrast it with React's, where "the reconciliation algorithm cannot make any assumptions about the incoming virtual DOM tree."
Vapor Mode goes the rest of the way. A component that opts in compiles to direct DOM operations with no virtual DOM at all, and since this is the claim in here that's most likely to go stale, here's exactly where it stands:
- Vapor first shipped in Vue 3.6.0-alpha.1 in July 2025.
- The first beta landed in December 2025, with the team saying Vapor now had "feature parity with all stable features in Virtual DOM mode" (Suspense in a Vapor-only app being the exception).
- 3.6 went to release candidate in July 2026. The newest one as I write this is 3.6.0-rc.9, from September 18.
- npm's
latesttag forvuestill points at 3.5.43. There's no stable Vue release with Vapor in it yet.
The RC release notes call Vapor "feature-complete" and "100% opt-in." For now they recommend it for two things: a performance-sensitive page inside an existing app or a small new app built entirely in Vapor. Opting in takes one attribute on the script tag, <script setup vapor>, and after that the only real decision is about the app: one created with createVaporApp() leaves the virtual DOM runtime out of the bundle entirely, while a normal createApp() app can mix Vapor components in through vaporInteropPlugin and keep everything else exactly as it was.
I compiled the Vue component from the tabs above with 3.6.0-rc.9 in both modes. The normal build returns a render function that builds vnodes, like Vue always has, while the Vapor build returns this:
ProductList.vue with script setup vapor, Vue 3.6.0-rc.9 (trimmed)
const t0 = _template("<input>")
const t1 = _template("<p> ")
const t2 = _template("<ul>")
// ...
const n1 = t1()
const x1 = _txt(n1)
_renderEffect(() => _setText(x1, "Picked: " + _toDisplayString(picked.value ?? 'nothing')))
// ...
const n2 = _createFor(() => (visible.value), (_for_item0) => {
const _on_pick = () => (picked.value = _for_item0.value.id)
const n4 = _createComponent(ProductRow, {
product: () => (_for_item0.value),
onPick: () => _on_pick
})
return n4
}, (p) => (p.id), 3 /* FAST_REMOVE, IS_COMPONENT */)
There's no render function here and no vnodes. The markup is turned into real DOM templates once and cloned from then on, the <p> gets exactly one _renderEffect, and when a row is picked that effect is the only thing in the whole component that runs: it sets the text of one text node, and that's it. Props go to the child as getter functions (product: () => (_for_item0.value)), so the child reads the current value itself instead of receiving a new props object. The component's setup runs once and never again. Turns out that's the Solid model, and Vue's own docs say so, describing Vapor as a "Solid-inspired compilation strategy."
The subset's real, though. Vapor components don't support the Options API, app.config.globalProperties or v-memo, and getCurrentInstance() returns null inside them, but the one I'd watch for in an existing app is events. Vapor delegates most listeners to document. The release notes spell out the catch: if any ancestor calls stopPropagation() "the delegated handler will not run." Mixing Vapor and virtual DOM components works for ordinary props, events and slots, but the notes still warn about "rough edges" with VDOM-based component libraries.
The Paths Met At The Compiler, Not At The Mental Model
Put the two compiled outputs side by side. The convergence is obvious. Both frameworks now ship a compiler that rewrites a component into code nobody would write by hand, and both compilers exist for the same reason: to skip work for things that didn't change.
The mechanism's where they still split, and honestly I think it's the most important difference left. React's compiled ProductList is still a function that runs on every single render and checks its 21 cache slots on the way through to reuse whatever it can, while Vapor's version runs once and then leaves individual DOM nodes to a handful of effects, so that component never renders a second time.
React chose that on purpose, and the same React Labs post from 2024 says so: "Our vision is for React to automatically re-render just the right parts of the UI when state changes, without compromising on React's core mental model." That core model is UI as a function of state, and signals would change it. The TC39 Signals proposal is still at Stage 1. Its README credits design input from the authors of thirteen named projects, Vue among them. React isn't one of them. Vue's docs say outright that "signals are the same kind of reactivity primitive as Vue refs." And 3.6 goes further: it rewrites the core of @vue/reactivity by porting alien-signals, a standalone signals library, for better speed and memory use, so a reactivity system that was always signal-shaped now literally has a port of one sitting at its center. (The state management landscape piece covers what signals look like when you add them to React from the outside.)
This one's the difference you feel every day, not just in benchmarks. In React I call setPicked(id) and the whole function runs again with new values and the props stay read-only snapshots the entire time, so what I reason about is renders, while in Vue I write picked.value = p.id and the state changes in place and whatever read it updates on its own. There I reason about dependencies. Someone fluent in one has to relearn habits in the other. No compiler touches that part.
The Server Moved Closer On Both Sides, Unevenly
The third shared direction is the server. It's the messiest one. React 19 made Server Components stable in December 2024, and in the Next.js App Router "layouts and pages are Server Components" by default, with 'use client' added only where a component needs state or event handlers. And there's nothing like that in Vue's core. Nuxt has server components: .server.vue files that render on the server as islands and add none of their code to the client bundle, but the Nuxt docs say they're "still marked experimental" and that on client-side navigation "each island on the destination page must be fetched from the server." That's a different shape from RSC, where server components are the default and a serialized payload carries the rendered server tree to the client. Nuxt's stronger server story is Nitro, the server engine that lets API routes live in the same project as the pages, and it's the part of Nuxt I'd point a React developer at first.
Moving work to the server isn't free on either side, and the Next.js 16.3 announcement says plainly that Server Components "made navigations feel slow." A big chunk of that release is tooling to fix it. So both camps agree the server should do more, they just don't agree on the unit: React made the component the unit of server work, while Nuxt works at the level of routes and server endpoints and keeps component islands experimental.
The Ecosystems Are Nowhere Near The Same Size
Here's the row that didn't converge at all. These are npm downloads for the week of September 20 to 26, 2026, pulled straight from the npm downloads API:
| Package | Weekly downloads |
|---|---|
react |
203,498,378 |
vue |
18,457,765 |
next |
67,256,843 |
nuxt |
2,364,072 |
Download counts measure installs, not people (every CI run counts, and so does every package that depends on these), but an 11x gap between the libraries and a 28x gap between the meta-frameworks isn't noise, and Stack Overflow's 2025 survey points the same way from the people side: 44.7% of respondents used React and 17.6% used Vue, 20.8% used Next.js and 4% used Nuxt.
One number from that same week made me look twice. babel-plugin-react-compiler got 17.8 million downloads, nearly as many as vue itself. That's not a typo. A big part of that is Expo. Its Babel preset depends on the plugin because Expo turns the compiler on by default, and babel-preset-expo alone got 10.9 million downloads in the same week. Still. A React build plugin now gets installed about as often as the entire Vue framework.
None of that makes Vue small. Eighteen million installs a week is a very alive framework. But size is what decides how easy hiring is and how many libraries work on day one and how many answers already exist for your strange edge case, and React has a lot more of it.
Where I'd Actually Make The Call
Okay, but Vapor benchmarks alongside Solid and Svelte 5. Isn't that exactly the kind of performance gap a React compiler can't close?
It's a real gap in the benchmark. I won't argue that. The Vue team cites the js-framework-benchmark for that claim, and a compiled React component that still re-runs on every render isn't going to match one that never re-runs. It just doesn't decide the choice for most products. Vapor is still a release candidate, it's opt-in per component, it supports a subset of Vue, and the Vue team itself recommends it for one hot page or a small new app. And in my view the diff is rarely what makes a product feel slow anyway (the network and the amount of JavaScript shipped usually are), so if one screen really is diff-bound that's a reason to try Vapor on that one screen and not a reason to pick a whole framework.
So here's how I'd choose, in order.
The codebase you already have. If there's a working React or Vue app, keep it. In 2026 a rewrite from one to the other buys almost nothing on performance. Turning on the React Compiler is far cheaper than a migration. So is trying Vapor on the one slow page.
The team you have and the one you'll hire. A team that knows Vue deeply will ship better Vue than it'd ship average React, and the reverse holds too. When people switch, it's the mental model from earlier that costs them. The APIs are the easy part. If you're hiring a lot, the survey numbers are the honest tiebreaker: the React pool is about two and a half times bigger.
The meta-framework that fits the product. This is the one I'd spend the most time on. It now decides more of the architecture than the UI library does. Next.js if the product wants server components as the default and the team is ready for the server and client boundary and its caching model. Nuxt if the product wants a full-stack Vue app with server routes next to the pages and a more gradual path to the server. Most of the differences between React and Vue that still matter in 2026 live one layer up, in these two.
If your team already knows one of them well, that's your answer. It probably always was, we just had more interesting things to argue about.
Thanks for reading! English isn't my first language, so I use AI to polish the grammar. Everything else here - the ideas, the code, the opinions - is mine.
Enjoyed this one? Let's stay in touch — I'm on LinkedIn, always happy to chat, swap ideas, or just say hi. 👋

Top comments (4)
The
filterexample was especially interesting to me. 😸Your hand-written
useMemohad exactly the cache boundary you wantedproductsandquery. But the compiler grouped the filter into a larger cache block that also depends onpicked, so selecting a row causes the filter to run again.That feels like a really good example of the limit of compiler inference.
ReactCompiler can remove a lot of manual memoization, but it also proves why manualuseMemois still useful when we need precise control over the cache boundary.I’ve read quite a bit of
React’s internals over the years, and I’m also still unsure about the direction of Server Components. I sometimes wonder whether making the component itself such a central unit of server work was the right trade-off. The fact that Next.js has had to work on making RSC navigation feel faster makes that question even more interesting to me.On the rendering side, I personally think
Vue3 has had a technical advantage over React for quite a while. Its compiler-informed VDOM, patch flags, and increasingly fine-grained update model are very compelling, and Vapor pushes that much further. From a purely technical perspective, I often preferVue’s approach.But I completely agree with your point that framework selection is rarely decided by technical quality alone.
SolidandSvelteare obvious examples.Their performance can be excellent, but adoption is harder because fewer developers know them. Even between
VueandReact, I’ve been involved in cases whereReactwas chosen despite preferringVuetechnically, because hiring, ecosystem size, existing team knowledge, and long-term maintainability mattered more.What I think is changing now is AI.
As AI-assisted development gets better, “our team doesn’t know this framework yet” may become a much smaller problem. That could make it easier to choose technologies based more on what we actually want to use.
But it also creates a completely new selection criterion:
A technically excellent library may still be difficult to adopt if agents don’t know it well, generate outdated patterns, or cannot reliably debug it. So I think future technology selection won’t only be about performance, DX, ecosystem, community, and hiring anymore.
We’ll also have to consider how well AI can support the stack.
I’m actually working on a
Nuxt→Reactmigration right now.Reactwasn’t chosen because I think it is technically better thanVue. We chose it after considering the future team, the skills of the existing members, and the development speed we can get with AI.That difference between “the technology I think is technically better” and “the technology that makes the most sense for the organization” may become even more interesting in the AI era. 🐱
Thanks for this 😊
The AI angle is one I didn't have in the article, and my guess is it mostly helps React, since how well an agent knows a framework follows how much code exists for it. Vapor is a small example of the flip side, it's new and it drops the Options API, so I'd expect agents to keep writing code it doesn't support for a while.
I ended up with both in one product. Vue on the web, React Native on the phones, and I write both.
What costs me time switching between them is where state is allowed to change. In Vue I mutate a ref and the screen follows. In React I catch myself doing the same thing and then spend a while on why nothing re-rendered.
So your mental model section matches what I see more than the compiler part does. The output converging does not help my hands much.
Did the comparison use production builds with the same update trace? That would separate compiler output from runtime behavior.
Some comments have been hidden by the post's author - find out more