Reactivity Models
Re-run and diff, tracked proxies, compile-time analysis, fine-grained signals, zone-based change detection: five ways of finding out that something changed.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How does a framework find out that something changed, and what does each answer cost?
A person types into a filter, toggles a row, or receives a push update. They expect the screen to agree with reality before they have finished looking at it.
Frameworks all do the same thing with different syntax — you change data, they update the DOM. So the choice is templates and taste, and any advice from one transfers to the others.
They do not do the same thing. React re-runs your component function and compares the results; Solid never re-runs it after the first time. "Wrap it in a memo" is a sentence from one model being spoken inside another, where it fixes nothing and costs an allocation.
- They do not do the same thing. React re-runs your component function and compares the results; Solid never re-runs it after the first time. "Wrap it in a memo" is a sentence from one model being spoken inside another, where it fixes nothing and costs an allocation.
- The difference is invisible at ten components and decisive at four thousand rows. Whether a keystroke re-runs one function or four hundred is not a stylistic matter; it is your entire interaction latency (Interaction Responsiveness).
- Bundle cost has a different *shape* per model, not just a different size. A compiler ships less shared runtime and more code per component; a runtime framework ships a fixed runtime and thinner components. Which is smaller depends on how many components you have, which is a property of your app (The Real Cost of JavaScript).
- Server rendering and hydration behave differently under each model, and that difference shows up as a whole class of production bug — mismatched markup, effects that run twice, state that exists on the client and not on the server (Hydration Mismatch).
- The models are converging. Signals now exist in frameworks that began without them and compilers now exist in frameworks that began as pure runtimes, so any comparison memorised as a fact has a shelf life measured in releases.
What is actually happening
In the browser, not in the framework.
- Re-run and diff (React). A state update marks a component dirty. Its function runs again, top to bottom, and returns a fresh description of the UI. A reconciler compares that description with the previous one and commits only the differences as DOM calls (The React Mental Model).
- Dependency-tracked proxies (Vue). Reactive state is wrapped so reads are recorded and writes are broadcast. A component's render is an effect: whatever it read while rendering becomes its dependency set, and writing to any member of that set schedules that component to render again (The Vue Mental Model).
- Compile-time analysis (Svelte). The compiler reads the component as source, derives which parts of the template depend on which values, and emits code that updates exactly those parts. Work the other models do while the user waits has already happened before the file was served (The Svelte Mental Model).
- Fine-grained signals (Solid). A signal is a value with a subscriber list. The component function runs once, creating real nodes and small computations around the dynamic parts. Setting a signal re-runs only the computations that read it, and each writes into the node it owns (The Solid Mental Model).
- Change detection plus signals (Angular). A zone historically patched asynchronous browser APIs so the framework could learn that *something* had happened, then walked the component tree checking bindings. Signals give the same framework a targeted path to the same job, and the two currently coexist (The Angular Mental Model).
- Underneath all five, the same thing happens: ordinary DOM calls mutate the document, the browser invalidates style and possibly layout, and a frame is produced (The Rendering Pipeline). No framework draws pixels, and none of them has a route around that pipeline.
What this makes the browser do
And which of it is avoidable.
- Every model eventually issues
createElement,setAttribute,textContentandinsertBeforecalls. The models differ in how much JavaScript runs to decide *which* calls, not in what the calls cost (What a Mutation Costs). - Re-run-and-diff spends main-thread time proportional to the size of the re-rendered subtree even when nothing in it changed. That work is real, it is on the only thread that can paint, and it is why the "just re-render everything" model needs escape hatches (What the Main Thread Owns).
- Fine-grained models spend memory instead: one subscription per reactive binding, held for the lifetime of the view. Cheap per update, not free at rest.
- A compiler moves work out of the browser but not out of the bundle: generated update code is bytes the browser still parses and compiles (The Real Cost of JavaScript).
- All five batch updates and flush before a rendering opportunity, so several state writes in one task produce one DOM pass rather than several (The Rendering Opportunity).
Seven axes, five answers
A comparison is only useful if it names what it is comparing. These seven axes are the ones that change how an application is written and what it costs at runtime; everything else — directive syntax, file layout, naming — is genuinely taste.
Read the table with the notes underneath it. Each row is true enough to be useful and misleading enough to be dangerous on its own, which is why the misleading part is written down rather than left for a reader to discover in production.
- Where the reactivity row misleads — "re-run and diff" sounds wasteful and "fine-grained" sounds free. Re-running a small component is cheap and predictable; fine-grained subscriptions are cheap per update but are memory held for the life of the view.
- Where the rendering row misleads — "no virtual DOM" is a description of a strategy, not a benchmark result. The generated code still makes DOM calls, and the DOM calls are the part the browser charges for (What a Mutation Costs).
- Where the compiler row misleads — compile-time work is not free work. It moves cost from the user's device to your build, and it puts semantics inside a tool: what the compiler cannot statically see, it cannot make reactive.
- Where the state row misleads — immutable and mutable here describe the update protocol, not what your data may be. Every one of these frameworks is used with plain objects and derived values; the difference is who is told about the change (Derived State).
- Where the server-rendering row misleads — "mature" hides very different models. Streaming, islands and server components are architectural choices that cut across framework choice (Choosing a Rendering Strategy).
- Where the bundle row misleads — a compiled framework's advantage narrows as component count grows, and every measurement of "framework size" that excludes the router, the data layer and the component library is measuring the wrong thing (Bundle Analysis).
- Where the ecosystem row misleads — the largest ecosystem is also the largest surface of abandoned packages, and a complete first-party stack removes decisions you might have wanted to make (Choosing a Framework).
| Aspect | React | Vue | Svelte | Solid | Angular |
|---|---|---|---|---|---|
| Reactivity model | Re-run the component, diff the result | Proxy tracks reads, writes trigger the render effect | Compiler derives the dependency graph from source | Signals with per-binding subscribers | Tree-walking change detection, plus signals |
| Rendering model | Virtual node tree reconciled against the previous one | Virtual node tree, guided by compiler patch hints | Generated imperative create/update code | Real nodes created once; bindings updated in place | Compiled template instructions bound to a view |
| Compiler vs runtime | Mostly runtime; compiler-assisted memoization is newer | Split: template compilation plus a tracking runtime | Heavily compile-time | Compile JSX to node creation; reactivity at runtime | Ahead-of-time template compilation plus a large runtime |
| State model | Immutable updates via setters; state is per-component | Mutable reactive objects and refs | Assignment to a declared value is the update | Read and write through signal accessors | Services with DI, RxJS streams, and signals |
| Server rendering | Mature, with streaming and a server-component model | Mature, with a first-party meta-framework | Mature via its first-party app framework | Supported, with fine-grained hydration | Supported, with hydration and partial replay |
| Bundle cost | Fixed runtime plus small components | Fixed runtime plus small components | Small runtime plus larger generated components | Small runtime plus generated node creation | Larger runtime, reduced by build-time removal of unused code |
| Ecosystem | Largest library and hiring pool | Large, with strong first-party defaults | Smaller, with strong first-party defaults | Smallest of the five | Complete first-party stack; smaller third-party surface |
The same four questions, five times
Strip the vocabulary away and every model answers the same four questions in order. Naming them is what lets you carry knowledge between frameworks instead of relearning it: the questions are stable even though the answers are not.
Notice that only the last step touches the browser, and it is identical in all five. Everything upstream is a strategy for arriving at a smaller list of DOM calls with less work — and the strategies differ in whether they spend build time, main-thread time, or memory to get there.
- 11. Detect that something changed
React: a setter marked a component dirty. Vue: a proxy write notified subscribers. Svelte: generated code ran on assignment. Solid: a signal notified its subscribers. Angular: a zone reported activity, or a signal was set.
fails by Mutating a value the model is not watching. The change is real and the UI never hears about it.
- 22. Decide what might be affected
React: this component and, by default, its children. Vue: the components whose render effect read the value. Svelte: the statements the compiler proved depend on it. Solid: the exact computations subscribed. Angular: the checked branch of the component tree, or the signal's consumers.
fails by Over-broad dependencies — reading a whole object when one field was needed — which turns a targeted update into a subtree update in any model.
- 33. Produce the new UI description
React and Vue build a new node tree to compare. Svelte, Solid and Angular skip this step: what to write was decided at compile time or at subscription time.
fails by Doing expensive work inside render — sorting, formatting, deriving — so the cost is paid on every pass rather than when the input changes (What a Component Costs to Render).
- 44. Commit DOM calls
Identical in all five: create, remove, move and mutate nodes and attributes through the ordinary DOM API.
fails by Replacing nodes that could have been updated — usually a keying problem — which destroys focus, selection, scroll position and any DOM state (Reconciliation and Keys).
- 55. Browser rendering
Style invalidation, layout if geometry could have changed, paint, composite, frame.
fails by A cheap update that nonetheless invalidates layout for a large subtree. No framework can make this cheaper; only fewer or better-shaped mutations can (The Cost of a Change).
Steps 1–3 are where the frameworks differ. Steps 4 and 5 are the browser, and they are where the user's time usually goes.
Two ends of one spectrum
It helps to hold the extremes in mind. At one end, a change invalidates a *region* of the UI and the framework works out what within it actually differs. At the other, a change is delivered directly to the individual bindings that read the value, and nothing else is examined at all.
Neither end is a strict improvement. The coarse end is resilient — it cannot forget to update something, because it re-derives everything in the region — and its cost is predictable but never zero. The fine end has near-zero cost per update and a sharper failure mode: a value read outside a tracking scope simply is not reactive, and the bug is a stale pixel with no error attached to it.
How to build it
Most important first.
- Learn the model before the API. Almost every framework question that survives more than an hour — "why did this run twice", "why did this not update", "why is this slow" — is a question about which of these five mechanisms you are standing in.
- Compare on the seven axes below rather than on impressions: reactivity model, rendering model, compiler versus runtime work, state model, server rendering, bundle cost, ecosystem. Any two of them can be traded against each other, and no framework wins all seven.
- Treat the escape hatches as part of the model, not as failures of it.
memoin React,shallowRefin Vue, untracked reads in Solid andOnPushin Angular all exist because each model has a case it handles pessimistically by default. - Measure with the framework's own devtools *and* the browser's. The framework tells you which component re-rendered; only the Performance panel tells you what that cost the main thread (Measure Before Optimising).
- Assume convergence. Write application logic that does not depend on the detection mechanism — pure functions over plain data, with reactivity applied at the edges — and upgrades stay boring (Who Owns This State?).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- No reactivity model produces an accessible UI. All five will happily render a
divwith a click handler, and the accessibility tree will faithfully report an element with no role, no name and no keyboard behaviour (Semantics Before ARIA). - Models that replace DOM nodes rather than updating them can destroy focus. If the node holding focus is unmounted and a new one takes its place, focus falls back to the body and a keyboard user loses their position with no announcement (Focus Management).
- Models that update text in place are gentler on assistive technology, because the node identity a screen reader is tracking survives the update (Node Identity Across Updates).
- Batched updates help announcement: several writes flushed as one DOM change produce one live-region announcement instead of a burst that the user cannot follow (Live Regions and Announcement).
What can go wrong
- Advice ported across models. Aggressive memoization in a fine-grained framework adds overhead to a path that was already targeted; skipping keys in any of them corrupts list state (Reconciliation and Keys).
- Mutating state the model cannot see: a plain array pushed into in React, a property added outside the proxy in Vue, a value the Svelte compiler never saw assigned. The UI silently stops matching the data.
- Over-subscribing. A component that reads one broad object re-renders on every change to any of its fields, whichever model you are in — the models differ in how easily you can narrow it.
- The escape hatch as a habit. Memoizing everything replaces render cost with comparison cost plus retained memory, and the comparison runs whether or not it saves anything (Memoization).
- Benchmarks as evidence. A synthetic table-of-rows benchmark measures list reconciliation, which is a small and unusually favourable slice of a real application (Measure Before Optimising).
- State can be written between a render starting and its result being committed. Every model has an answer — batching, scheduling, or re-running — and the answers differ in whether an intermediate state can ever be painted.
- Asynchronous data arriving out of order will happily overwrite newer data with older in all five models. The reactivity system tracks *that* a value changed, never *when it was requested* (Out-of-Order Responses).
- Every one of these frameworks escapes interpolated text by default, which removes the most common XSS sink but not the concept (Cross-Site Scripting).
- Each keeps a deliberate raw-HTML door — differently named, identically dangerous — and each one bypasses the escaping the rest of the framework gives you (Sanitization and Trusted HTML).
- None of them enforces anything about authorization. A component that renders conditionally on a permission flag is a hint to the user, not a control (Authorization-Aware UI).
- The framework is also a dependency tree. The reactivity model has no bearing on whether a transitive package in the build can read your build environment (Third-Party Scripts and the Supply Chain).
- "The virtual DOM is fast." A virtual DOM is not faster than a targeted DOM update — a fine-grained model beats it on update work by construction. It is faster than re-creating a subtree from scratch on every change, and it buys a programming model in which you describe the result instead of the transition. Both halves of that sentence are needed for it to mean anything.
- "React renders to pixels." No framework renders anything. They compute DOM calls; the browser turns the resulting document into pixels through style, layout, paint and composite, at exactly the same cost regardless of who made the calls (The Cost of a Change).
- "Signals are just observables with better marketing." They are a specific pairing: automatic dependency tracking at read time, plus glitch-free propagation of the derived graph. The tracking is the part that changes how code is written.
- "Compiled means no runtime." Every compiled framework ships a runtime; it is smaller, not absent, and the generated per-component code is bytes too.
- "The fastest framework in the benchmark is the fastest for us." The benchmark measures keyed list reconciliation on a fast machine. Your users are waiting on your data layer, your bundle and your third-party scripts.
Measuring it, and what changes in the field
- The framework's devtools answer "what re-rendered and why": React's Profiler, Vue's component inspector, Angular's DevTools change-detection profiler, Solid's and Svelte's inspectors.
- The Performance panel answers "what did that cost": scripting time attributable to the framework, followed by the style, layout and paint the resulting mutations caused (A Mental Model of the Devtools).
- Long-task and interaction data from real users tells you whether re-render cost is reaching anyone. A model difference that never leaves the noise floor on real devices is not a problem you have (Real User Monitoring).
- Bundle analysis attributes bytes to the framework runtime versus your components — the only honest way to compare compiled and runtime models (Bundle Analysis).
- On a slow device, per-update JavaScript is amplified by roughly the CPU gap, so the model that does more work per change is disproportionately worse there — and disproportionately fine on the machine it was chosen on.
- On large lists, the differences are at their sharpest, which is also why list benchmarks over-represent them. Virtualisation flattens most of the gap by making the rendered set small again (List Virtualization).
- On a page with few components and infrequent updates, all five models are indistinguishable to a user and the choice is entirely about ecosystem, hiring and server rendering.
- In a long-lived tab, fine-grained subscriptions accumulate with the view tree; a leak of subscriptions looks exactly like a leak of listeners (Memory Leaks).
- Re-running components buys a simple mental model — the UI is a function of state — and pays for it with work proportional to the tree rather than to the change.
- Fine-grained reactivity buys minimal update work and pays with a stricter contract: values must be read through their accessors, in a tracking scope, and destructuring can silently opt out of reactivity.
- Compile-time analysis buys a small runtime and pays with a build step that owns the semantics: what the compiler cannot see, it cannot make reactive.
- Comparing at all costs something. Every axis you compress into a table hides a constraint — team, existing code, hiring, deadline — that usually matters more than the mechanism (Choosing a Framework).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- FRAMEWORK-SPECIFICEvery claim in this lesson is about a named framework's current strategy: React re-runs components and diffs, Vue tracks reads through proxies, Svelte analyses at compile time, Solid subscribes per binding, Angular combines tree-walking change detection with signals. Substituting one name for another in any of those sentences makes it false.
- SPEC-EVOLVINGThese are moving targets rather than fixed designs. Signals have been added to frameworks that started without them, compiler-assisted memoization has been added to React, and Vue and Svelte have both shipped compiler work that narrows what their runtimes must examine. Treat the axes as durable and any specific mechanism as a snapshot.
- SIMPLIFIEDEach framework is presented as one mechanism, and each really has several: React schedules and can interrupt work, Vue combines proxy tracking with compiler patch hints, Angular runs two detection systems at once. The single-mechanism summary predicts behaviour well and understates the engineering.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Compilers & Programming Languages — the static analysis that lets a build tool know which template expression depends on which variable is ordinary dependency and dataflow analysis wearing a UI hat.
- — Programming Languages & Runtime Internals — proxies, getters and the observer graph behind signals are language-level mechanisms; how they are optimised by the engine decides how cheap "cheap per update" really is.