Every mainstream frontend framework solves the same problem: the DOM is slow to mutate and easy to mutate incorrectly, so don't let application code touch it directly. Where they diverge is HOW they figure out which mutations are actually necessary. React re-runs component functions and diffs the result against a tree it remembers. Vue tracks exactly which pieces of state a given render touched and re-runs only the renders that depend on what changed. Svelte skips the runtime entirely and compiles your reactive intent into direct DOM-mutation instructions at build time. These aren't stylistic differences — they're different answers to "how do we minimize wasted work," with different runtime costs and different failure modes.
Phase 1: The Component Tree — Props, State, and One-Way Data Flow
Before the internals, the shared model: a component tree, where data flows down as props and events flow up as callbacks. This unidirectional flow is what makes a state change traceable — you can always find the single component that owns a piece of state, because ownership is not implicit or shared, it's declared.
The Shared Vocabulary
- Props: data passed from parent to child, read-only from the child's perspective — a child that needs to change a prop's value must ask the parent via a callback, not mutate it directly.
- State: data a component owns and can change; a state update is what triggers the framework to consider re-rendering that component and its subtree.
- Events: the upward channel — a child emits a signal (a DOM event, a Vue emit, a callback prop invocation) and the parent decides how to respond, keeping the child ignorant of its parent's internals.
- Derived state: values computed from props or state rather than stored redundantly — storing a derived value in its own state slot is the single most common source of state-sync bugs in component trees.
import { useState } from 'react';
function Counter({ label, onMilestone }) {
const [count, setCount] = useState(0);
function increment() {
const next = count + 1;
setCount(next);
if (next % 10 === 0) onMilestone(next); // event flows back up
}
return (
<div>
<p>{label}: {count}</p>
<button onClick={increment}>+</button>
</div>
);
}<script setup>
import { ref } from 'vue'
const props = defineProps<{ label: string }>()
const emit = defineEmits<{ milestone: [value: number] }>()
const count = ref(0)
function increment() {
count.value++
if (count.value % 10 === 0) emit('milestone', count.value)
}
</script>
<template>
<div>
<p>{{ props.label }}: {{ count }}</p>
<button @click="increment">+</button>
</div>
</template>Phase 2: React's Fiber — Reconciliation Is a Diff, and Diffing Is Interruptible Work
React's core mechanism hasn't changed since its earliest versions: calling setCount doesn't mutate the DOM, it schedules a re-render. React re-invokes the component function, producing a new tree of React elements (plain JS objects describing what the UI should look like), and diffs that tree against the previous one to compute a minimal set of DOM operations. What changed since React 16 is Fiber — a rewrite of the reconciler's internal data structure that makes this diffing work interruptible instead of a single synchronous pass.
Pre-Fiber React walked the element tree recursively with the call stack itself as the traversal mechanism — once started, a large tree's diff ran to completion, blocking the main thread for however long it took, which could drop frames on a big update. Fiber replaces the call-stack traversal with an explicit linked-list data structure (a "fiber" per component instance, each pointing to its child, sibling, and return/parent) that React walks manually, unit of work by unit of work. Because React itself controls the loop, it can pause after any fiber, yield back to the browser to handle a higher-priority task like a keystroke, and resume later — the diff becomes cooperatively scheduled instead of a single uninterruptible block.
What Fiber Actually Buys You
- Priority-based scheduling: React can classify updates (a user typing in an input vs. a background data refresh) and let the higher-priority one interrupt and finish first, which is the mechanism behind startTransition and useDeferredValue.
- No dropped frames on large diffs: without an interruption point, a big enough tree diff runs as one long synchronous task and blocks the main thread for its entire duration, which is exactly the jank Fiber was built to eliminate.
- The commit phase is still synchronous and cannot be interrupted: once React starts writing the computed mutations to the real DOM, it finishes in one pass — pausing mid-commit would leave the UI in a visually inconsistent, half-updated state.
Phase 3: Vue's Reactivity — Proxies That Know Exactly What Changed
Vue 3 takes a structurally different approach: instead of re-running a component function and diffing the result to discover what changed, Vue instruments the data itself so it always knows precisely which state was read by which render, and can invalidate only that render when the specific piece of state it depends on is mutated. This is done with JavaScript Proxy objects wrapping reactive state (what ref() and reactive() return), intercepting get and set operations transparently.
The mechanism has two halves. During a component's render, every reactive property it reads through the proxy's get trap registers that render function as a subscriber — this is dependency tracking, and it happens automatically, with no dependency array to maintain by hand. When a reactive property is later written through the proxy's set trap, Vue looks up exactly which subscribers depend on that specific property and re-runs only those — this is targeted invalidation, and it's why Vue doesn't need a virtual DOM diff to know what to update: it already knows, because it tracked the read.
// Simplified model of what Vue's reactive() actually does under the hood.
function reactive(target) {
return new Proxy(target, {
get(obj, key) {
track(obj, key); // register the CURRENTLY RUNNING effect as a subscriber
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
trigger(obj, key); // re-run ONLY the effects that read this exact key
return true;
}
});
}
// Contrast with React: a state setter doesn't know who reads the value.
// It just schedules the whole component function to re-run, and diffing
// afterward is what discovers what actually changed in the output.The Practical Consequences of Proxy-Based Reactivity
- Fine-grained updates by default: a Vue component with ten independent reactive properties only re-renders the parts of its template that actually read the one property that changed, without memoization hints from the developer.
- Reactivity has edges: destructuring a reactive object's properties into plain variables (
const { count } = reactive({...})) breaks the proxy connection — the variable is now a disconnected primitive, which is exactly whytoRefs()exists and whyref()unwraps differently in templates vs. script. - Arrays and Maps needed special-casing pre-Proxy: Vue 2's Object.defineProperty-based reactivity couldn't intercept array index assignment or new property addition without explicit Vue.set — Vue 3's Proxy approach intercepts these natively, which is the single biggest reactivity-correctness improvement in the rewrite.
Phase 4: Svelte — Deleting the Runtime by Moving Reactivity to Compile Time
React ships a reconciler to the browser. Vue ships a reactivity runtime to the browser. Svelte ships neither — a Svelte compiler analyzes your component at build time and generates imperative, surgical DOM-update instructions directly, because it can statically see every place a reactive variable is read and written in the source. There is no virtual DOM to diff and no proxy to intercept at runtime, because the compiler already did the dependency analysis Vue does dynamically and React avoids entirely by re-rendering.
<script>
let count = $state(0); // Svelte 5 rune — compiler tracks every read/write of `count`
</script>
<p>Count: {count}</p>
<button onclick={() => count++}>+</button>
<!-- Compiles roughly to:
text_node.data = count; // direct DOM write, generated at build time
No virtual DOM, no diff, no runtime dependency graph traversal --
the compiler already knows this text node depends on `count`. -->Phase 5: Choosing Between Them — A Real-Time Analytics Dashboard
Concretely: a real-time analytics dashboard rendering dozens of live-updating charts and tables, streamed over a WebSocket, viewed by internal ops teams for hours at a stretch. This is a good stress test because it has a specific performance profile — frequent, high-volume, partial state updates — that makes the framework's update strategy matter more than it does for a typical CRUD admin panel.
| Factor | React | Vue 3 | Svelte |
|---|---|---|---|
| Update strategy under high-frequency partial updates | Re-renders component function, relies on memo/useMemo to avoid wasted diff work — easy to under-optimize by default | Fine-grained by default — a single WebSocket-driven ref update only re-renders the DOM nodes that actually read it | Compile-time surgical updates — comparable fine-grained behavior to Vue, with a smaller runtime footprint |
| Ecosystem depth for a data-viz-heavy dashboard | Largest — most charting libraries, state managers, and internal tooling integrations exist first for React | Strong and growing, slightly smaller pool of dashboard-specific component libraries than React's | Smallest of the three — fewer prebuilt data-grid/chart integrations, more custom glue code expected |
| Hiring pool / team ramp-up | Largest hiring pool, most engineers already know it | Smaller than React's but still substantial, generally fast ramp-up for React engineers | Smallest hiring pool — likely means training existing engineers rather than hiring for it directly |
| Bundle size / initial load for an internal tool | Larger baseline runtime; less critical for an internal ops tool with no strict Core Web Vitals/SEO requirement | Smaller than React's baseline, still a real runtime shipped to the client | Smallest shipped runtime — most relevant when the dashboard itself needs to load fast on constrained networks |
The actual decision for this scenario: Vue 3, specifically because the workload's defining characteristic — high-frequency partial state updates across many independent widgets — is the exact case where React's re-render-then-diff model requires deliberate memoization discipline to avoid wasted work, while Vue's dependency-tracked reactivity gets fine-grained updates by default. If the team were already a large, all-React organization with existing component libraries and hiring pipelines built around it, that ecosystem gravity would reasonably outweigh the architectural edge — React with disciplined memo/useMemo usage and virtualized lists handles this workload fine in practice, just with more manual performance work along the way.
<script setup>
import { ref, computed } from 'vue'
import { useWebSocketMetric } from '@/composables/useWebSocketMetric'
const props = defineProps<{ metricId: string }>()
// Only this card's subscribers re-render when THIS metric updates --
// a dashboard with 40 of these cards touches 1 DOM subtree per tick,
// not all 40, because Vue tracked exactly which template reads this ref.
const { value, trend } = useWebSocketMetric(props.metricId)
const formatted = computed(() => value.value.toLocaleString())
</script>
<template>
<div class="metric-card" :class="trend">
<span class="value">{{ formatted }}</span>
</div>
</template>Closing: The Model Underneath Is What You're Actually Choosing
Picking a frontend framework by feature-list comparison misses the part that actually matters at scale: React, Vue, and Svelte encode three different bets about where computation should happen — at render time via diffing, at mutation time via tracked dependencies, or at build time via static analysis. Each bet has a corresponding cost surface, and that surface is what shows up as jank, bundle size, or memoization boilerplate months into a project, not on day one.
None of the three is strictly better — a large React codebase with disciplined memoization performs well, a Vue codebase gets fine-grained updates without that discipline, and a Svelte codebase ships the least runtime code at the cost of a more constrained mental model. Know which bet your product's actual workload rewards before the framework choice ossifies into a migration project.