The Vue Mental Model
Reactive state records who read it. A component re-renders when a reactive value it actually read during its last render has changed — not because its parent did.
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 Vue know which components to re-render, without being told?
Someone updates one field in a settings panel. They expect that field's dependent summary to change and the rest of the panel to sit still.
Vue watches my data object and re-renders the component when it changes. It is React with a mutable data object instead of setters.
It does not watch the object; it watches the *reads*. A property nobody rendered has no subscribers, and writing to it re-renders nothing — which is a feature until you assume otherwise while debugging.
- It does not watch the object; it watches the *reads*. A property nobody rendered has no subscribers, and writing to it re-renders nothing — which is a feature until you assume otherwise while debugging.
- The tracking is per-read, so it changes when your template changes. A conditional branch that stops reading a value stops depending on it, and starts again when the branch is taken.
- Adding a property to a plain object is only reactive if the object was made reactive in a way that can observe additions, and replacing a reactive object with a plain one silently throws away the tracking.
- Destructuring a reactive object gives you plain values. The read happened at destructure time, the connection is gone, and the resulting variable will never update.
- "React with mutation" is misleading in the direction that matters: a parent re-rendering does not automatically re-render a child, because the child's dependencies are its own.
What is actually happening
In the browser, not in the framework.
- Reactive state is instrumented. State created through the reactivity API is wrapped so that reading a property can be recorded and writing to it can be broadcast. Reads and writes look like ordinary property access in your code, which is the entire ergonomic point.
- An effect is a function with a dependency set. While it runs, the system notes which reactive properties it read. Those become its dependencies, and it is subscribed to each.
- A component's render is an effect. Rendering a component is running a function that reads reactive values; whatever it read becomes that component's dependency set for the next cycle.
- A write triggers the subscribers of that property. Not the whole store, not the parent, not the tree — the effects that actually read it. This is why the update granularity is the component, and why an unrelated sibling does not run.
- Derived values are effects too. A computed value tracks what it reads, caches its result, and invalidates only when one of its own dependencies changes (Derived State).
- The result is still a virtual node tree, compared against the previous one — but the template compiler annotates it with what can change, so the comparison examines the dynamic parts rather than walking everything (Reconciliation and Keys).
What this makes the browser do
And which of it is avoidable.
- Proxy machinery on every read of reactive state. Individually tiny; in a loop over ten thousand rows it is ten thousand extra operations that a plain array would not have cost.
- Maintaining subscription sets: one per reactive property that anything read, held for as long as the reading effect lives (Memory Leaks).
- Re-running the render function of exactly the components whose dependencies changed, then diffing their output — the same virtual-node comparison React does, over a smaller set of components and with compiler hints about where to look.
- Committing DOM changes through the ordinary DOM API, at the ordinary cost (What a Mutation Costs).
- Avoidable work: reading a large reactive object where one property was needed, which subscribes the component to changes it does not care about.
Reads are subscriptions
The mechanism is small enough to state in two sentences. While an effect runs, every read of a reactive property is recorded against that effect. When a property is written, every effect that recorded a read of it is scheduled to run again.
Everything else follows. Components subscribe to what they render. Computed values subscribe to what they compute from. A conditional branch changes a dependency set when it changes which values are read. And a value that was never read has no subscribers, which is why writing to it does exactly nothing visible.
Where the connection gets cut
The characteristic Vue bug is not a slow update; it is an update that never happens. Because subscription is a side effect of reading, anything that reads once and keeps the result has unsubscribed itself, and there is no error to catch.
The two examples below are the same mistake at different scales: taking a value out of the reactive container and then expecting the value to keep changing. The fix is always to move the read to where it will happen again — inside a computed value, or inside the template.
import { reactive, computed } from 'vue'
const store = reactive({ user: { name: 'Ada' }, count: 0 })
// Read once, at module scope. `name` is a string now.
const { user } = store
const name = user.name
// Never re-evaluated: `count` was read outside any effect.
const doubled = store.count * 2import { reactive, computed } from 'vue'
const store = reactive({ user: { name: 'Ada' }, count: 0 })
// The read happens each time the computed re-evaluates,
// so the dependency is recorded and kept current.
const name = computed(() => store.user.name)
const doubled = computed(() => store.count * 2)Dependency tracking records reads that happen *while an effect is running*. A read at module scope is not inside an effect, so nothing is recorded and nothing can ever be triggered. The computed form re-reads on invalidation, which is what keeps the subscription alive.
What each escape hatch is for
Vue's reactivity API has several variants that look like near-duplicates and are not. Each exists because deep, automatic tracking is the right default and the wrong choice in a specific situation, and knowing which situation is most of the skill.
- A deeply reactive object — the default. Every nested property is tracked. Correct for application state; expensive for a large dataset you only replace wholesale.
- A shallow container — track the reference, not the contents. The right shape for a large list, a parsed document, or anything replaced rather than edited in place.
- A computed value — derived, cached, self-invalidating. The first thing to reach for instead of a watcher that assigns (Derived State).
- A watcher — for side effects at the boundary: fetching, logging, writing to storage. Not for keeping two pieces of state in step (State Synchronization).
- A raw, non-reactive value — for things that must never be proxied, such as a class instance from a third-party library that checks its own identity.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A destructured field is displayed | Correct on first render, frozen afterwards | Destructuring performed the read once; the binding is a plain value | Keep the container and read inside the template or a computed value. |
| A component reads a whole store object | It re-renders on every unrelated field change | The dependency is as broad as the read was | Read the specific fields, or split the store by ownership (Who Owns This State?). |
| Two watchers assign to each other's sources | Extra renders, occasional flicker, order-dependent results | A synchronisation loop where a derivation belonged | Replace with computed values; a derived value cannot desynchronise (Derived State). |
| A 20,000-row array is made deeply reactive | A long task at load, before any interaction | Proxy instrumentation across every nested property | Use a shallow container; the rows are replaced, not edited in place (List Virtualization). |
| DOM read immediately after a state write | Measures the previous layout | Updates are queued and flushed on the next tick | Wait for the flush before measuring; this is a scheduling boundary, not a bug (Layout Thrashing). |
How to build it
Most important first.
- Read narrowly. What you read is what you subscribe to, so pulling one field out inside a computed value is a smaller dependency than reading the object that contains it.
- Prefer computed values over watchers that assign. A computed value is derived and cannot go stale; a watcher that copies one piece of state into another creates two sources of truth and an ordering problem (Derived State).
- Keep large, non-reactive data out of the reactive graph. A ten-thousand-row dataset you only ever replace wholesale does not need per-property tracking, and the shallow variants exist for exactly this.
- Give lists keys from the data, for the same reason as every other framework in this module (Reconciliation and Keys).
- Keep the reactivity at the edges of your logic. Plain functions over plain data, made reactive where they meet the component, stay testable and portable (Testing Pure Logic).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Fine-grained component updates help: fewer nodes are replaced, so focus, selection and scroll survive updates that would be disruptive in a coarser model (Focus Management).
- A conditional that removes an element from the document removes it from the accessibility tree, with no announcement. If it held focus, focus is now on the body (The Accessibility Tree).
- Template directives that toggle visibility and directives that toggle presence are different for assistive technology: hidden-but-present is still in the tree unless it is hidden the way the accessibility tree respects.
- Teleported content — a dialog rendered elsewhere in the document — leaves DOM order and component order disagreeing. Focus trapping and return focus must be written explicitly (Accessible Component Patterns).
- Announce asynchronous results deliberately. A reactive value quietly becoming defined is a visual event and nothing else (Live Regions and Announcement).
What can go wrong
- Lost reactivity through destructuring or by passing a property value where the reactive container was needed. The symptom is a value that is correct once and never again.
- Over-broad dependencies: a component reads a whole store object and re-renders whenever any field in it moves (Prop Drilling, Context and Global State).
- Watchers that write state that other watchers watch, producing an update order nobody wrote down and a cascade that is hard to read in a profiler.
- Deep reactivity applied to a large structure, where the cost of instrumenting every nested property outweighs anything it buys.
- Escape hatches used to fix a symptom: forcing an update papers over a lost dependency and leaves the real one to be found later, usually by a user.
- Updates are queued and flushed once per tick, so several writes in one task produce one render. Code that reads the DOM immediately after a write sees the old DOM unless it waits for the flush.
- Watchers on data loaded asynchronously can run in an order determined by the network rather than by your code (Out-of-Order Responses).
- Template interpolation is escaped. The raw-HTML directive is not, and it is a first-class XSS sink wherever the content is not yours (Cross-Site Scripting).
- Dynamic attribute binding will happily bind a
javascript:URL to anhref. Escaping protects text, not URL schemes (Sanitization and Trusted HTML). - Reactive state in the client is fully visible and fully editable by the user, including anything a component decided not to display (What the Frontend Is Responsible For in Auth).
- Server rendering runs component setup on a server, where module-scoped state is shared between requests rather than per user — a leak with a much larger blast radius than a client-side one (Server-Side Rendering).
- "Vue re-renders when data changes." It re-renders when data *that this component read* changes. The distinction is the whole model.
- "A parent re-render re-renders the children." Children re-render when their own dependencies change or their props do, which is why component-level granularity is not the same as subtree granularity.
- "Mutation means no immutability discipline is needed." Shared mutable state has the same aliasing hazards here as anywhere; reactivity tells you it changed, not that the change was safe (Who Owns This State?).
- "Computed values are just cached functions." They are cached *and* dependency-tracked, so the cache invalidates itself. A hand-rolled cache does not.
- "Deep reactivity is always what I want." It costs proxy work per nested property, and for large read-mostly data the shallow variants exist because it often is not.
Measuring it, and what changes in the field
- The Vue devtools component inspector shows a component's current dependencies and what triggered its last render — the direct answer to "why did this update".
- The Performance panel shows what the resulting renders cost the main thread and what the DOM mutations cost after them (A Mental Model of the Devtools).
- Counting renders on a suspicious component under real interaction is the quickest test of an over-broad dependency (What a Component Costs to Render).
- On a large dataset, per-property tracking is a real cost at creation time, not just at update time. Making a huge structure deeply reactive is work you pay before anything has changed.
- On a slow device, the compiler hints matter more, because they reduce the amount of comparison work per update — the part that scales with CPU.
- In a long-lived tab, subscriptions that outlive their components hold both the effect and everything it closed over (Long-Lived Clients and Version Skew).
- Mutation ergonomics are excellent and the cost is that the tracking is implicit. "Why did this not update" becomes a question about a read you did not make, which is harder to see than a setter you did not call.
- Component-level granularity is a good default — narrower than re-rendering a subtree, broader than per-binding subscriptions — and it means an expensive component is still re-run whole for a one-character change.
- A compiler plus a tracking runtime means two systems to reason about when something is surprising, and the interesting bugs tend to sit exactly between them.
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-SPECIFICRead-time dependency tracking through instrumented state is Vue's mechanism. React does not track reads at all and re-runs whatever was marked dirty; Solid tracks reads too but subscribes individual DOM bindings rather than a whole component render; Svelte derives the same information at compile time; Angular checks a compiled view against its previous values.
- SIMPLIFIEDPresented as one runtime mechanism, Vue is really a template compiler and a reactivity runtime cooperating: the compiler marks which parts of a node tree can change so the runtime comparison can skip the static parts. The tracking story here is accurate and understates how much the compiler contributes.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — a proxy is a language-level interception mechanism, and how well an engine optimises property access through one determines the real cost of "tracked on read".