DevelopmentAll levels

Vue

Vue is a JavaScript framework for user interfaces whose reactivity system updates exactly the parts of the screen that read a value when that value changes. You never declare dependencies; in exchange, nearly every Vue bug that is not a typo is a value that quietly left the reactive system — copied out, destructured or replaced. The page opens with the whole Composition API on one screen, then covers tracking and the eight ways a value stops updating, computed against watch, templates and the compiler, composables, the move from Vue 2 to Vue 3, and five documented cases.

Current version
3.5 stable · 3.6 in RC
Reactivity
Proxy, tracked on read
Created by
Evan You · 2014

Vue on one screen: the API a component uses

A Vue component is usually one .vue file: a template that describes the screen, and a <script setup> block where everything else lives — state, values derived from it, reactions to changes and the contract with the parent component. Here is all of the script side first, because between them these functions are almost the whole of what you write. The table lists the Composition functions and compiler macros a component actually uses as of Vue 3.5 (September 2026), grouped by job, with what each one is for and the way each one gets misused. A version number marks a recent addition.

APIWhat it is forHow it gets misused
refAny value that changes — a number, a string, an object; read and written through .valueCopying .value into a local variable and expecting the variable to follow
reactiveAn object whose every property is tracked, with no .valueReplacing it as a whole or destructuring it — both leave the screen holding the old one
shallowRefA large value replaced as a whole: an API response, a list of ten thousand rowsChanging something inside it — only assigning .value is tracked
readonlyHanding state to a child or a composable that may read it but not write itBelieving it freezes the source — the original still changes, and the view follows
computedA value derived from other state, cached until something it read changesSide effects inside — a request, a write, a log; it may run any number of times, or never
watchDoing something when a named source changes: a request, storage, an animationAssigning to other state — a computed written the long way, section three
watchEffectThe same, with its sources found by running it once and seeing what it readsReading something by accident inside it — every value it touches becomes a trigger
onWatcherCleanupCancelling what the previous run of a watcher started, such as a request (3.5)Calling it after an await, where Vue no longer knows which watcher it belongs to
toRef / toRefsTaking properties out of a reactive object without cutting them off from itUsing them on a plain object, which has nothing to stay connected to
toValueLetting your own function accept a ref, a getter or a plain value alike (3.3)Calling it once at the top of the function, so later changes are never seen
markRaw / toRawKeeping a third-party object out of the proxy — a map, a chart, a class instanceWriting through toRaw: the write succeeds and nothing on screen updates
nextTickWaiting until the DOM shows a change you have just madeA general “wait a bit” — it waits for one update, no more
definePropsDeclaring what the component receives from its parent, with typesWriting to a prop from the child — the parent owns it
defineEmitsDeclaring the events the component sends up to its parentEmitting an event nobody declared, which neither TypeScript nor a reader can see
defineModelTwo-way binding for v-model without writing the prop and the event by hand (3.4)Changing an object received through it in place — the parent’s object changes with no event
provide / injectHanding a value down many levels without passing it through each oneA global variable in disguise: nobody can tell where the value came from
useTemplateRefReaching a DOM element or a child component from script (3.5)Reading it before the component is mounted, when it is still null
useIdAn id that ties a label to its input, the same on the server and in the browser (3.5)Keys for v-for — keys come from the data
onMounted / onUnmountedStarting and stopping what lives outside Vue: a listener, a timer, a chartAdding a listener in one and forgetting the other — one leak per visit to the screen
defineAsyncComponentLoading a component’s code only when it is needed — and, since 3.5, choosing when server-rendered HTML wakes upWrapping every component: each one is another network request

Reading creates the dependency

Vue wraps your state in a proxy — a JavaScript object that stands in front of another one and can observe every read and every write of its properties. A ref does the same for a single value by keeping it inside an object with a .value property. That one capability is the whole system, and everything else on this page is a consequence of it.

Vue wraps state in a proxy. While an effect runs — a render, a computed, a watcher — every property it reads records that effect as a dependency; that is tracking. When a property is written, only the effects that recorded it re-run; that is triggering. Nothing else is checked, which is why Vue needs no dependency array, and why a value read outside a reactive effect is invisible to the system.

While an effect is running — a component render, a computed value, a watcher — Vue knows which effect is current. Every property read during that run records the effect against itself. That is tracking. Later, when a property is written, Vue re-runs the effects recorded against that particular property, and nothing else. That is triggering. A component that did not read the value is not even asked, which is why a change in one corner of a large page costs the same as a change on a small one.

The machinery has also been getting cheaper. Vue 3.5 (September 2024) rebuilt the reactivity system and cut its memory use by 56%, with array operations up to ten times faster in some cases. Vue 3.6, a since July 2026, rebuilds it again on alien-signals, a small library designed for exactly this kind of tracking. None of it changes the rule. It changes how many tracked values a page can afford.

Eight ways a value stops updating

None of these throws an error. The value simply stops following its source, and the bug looks like a rendering problem. The numbers are referred to further down the page.

You wroteWhat actually happenedThe fix
1. const { count } = state from a reactive objectDestructuring reads the property once and copies the number out of the proxy.Keep state.count, or take toRefs(state) and destructure that.
2. const { count } = props inside a setup(props) functionThe same copy. Props are a reactive object like any other.toRefs(props), or <script setup>, where since 3.5 the compiler turns count into props.count for you.
3. watch(count, …) on a destructured prop in <script setup>The compiler rewrites reads, but watch receives the current number, not a source.watch(() => count, …) — pass a getter.
4. state = reactive({ … }) a second time, replacing the whole objectThe template still holds the first proxy. The variable now points at another one nobody reads.Use a ref and assign .value, or Object.assign(state, next).
5. useTotal(state.price) — a tracked value passed into your own functionThe argument is evaluated once, so the function receives a number and never sees a change.Pass toRef(state, "price") or () => state.price, and read it with toValue inside.
6. list.value.push(item) on a shallowRefA shallow ref tracks only the assignment of .value, not changes inside it — that is its purpose.Assign a new array, list.value = [...list.value, item], or call triggerRef(list).
7. Writing to the original object you passed to reactive()The proxy stands in front of the original. A write that goes around it is never seen.Write only through the proxy; do not keep the original in a variable.
8. A value read after an await inside watchEffectTracking only runs while the function runs synchronously. Anything read after the first await is not a dependency.Read every source before the first await, or use watch with explicit sources.
Reactivity lives on the proxy, not on the values inside it. Destructuring a reactive object reads the property once and copies the number out, so after state.count++ the copy still says 0 and nothing that uses it updates. toRefs hands out refs that still read through the proxy, so count.value is 1; a getter reads the proxy each time it is called, so it returns 1 as well. Take a ref or a getter out of a reactive object, never a copy.

Computed or watch: a value or an action

This is the decision Vue developers get wrong most often, and it has a clean rule. A computed derives a value: it is cached, it recalculates only when something it read has changed, and it must have no side effects. A watch reacts to a change by doing something — calling an API, writing to storage, starting an animation. The test is one question: are you producing a value or performing an action?

A watcher that ends by assigning to other state is almost always a computed written the long way, and the usual explanation — “it runs a tick late” — is close but not quite the mechanism. A computed is lazy: writing one of its sources only marks it stale, and the next read recalculates it on the spot, so the very next line of code already sees the new value. A watcher’s callback is queued instead. By default it runs after your current code finishes and before Vue updates the component’s . So the screen is never wrong — the render waits for the queue — but every line of script between the write and the queue reads the old value, and before the first change the copy holds nothing at all unless you add immediate: true.

A computed is lazy: writing price only marks it stale, so the very next line that reads total recalculates it and gets 40. A watcher that writes total is queued instead — by default it runs before the component’s DOM is updated — so any script between the write and the queue reads the old 20. The screen is right in both cases, because the render waits for the queue; the difference is what your own code sees in the meantime. Without immediate: true, the watcher’s copy would also be empty on the first render.

Those costs add up: a second piece of state that can disagree with the first, a value that is empty on the first render, and a loop on the day two watchers write to each other’s sources. In development Vue stops that loop after a hundred recursive updates and warns “Maximum recursive updates exceeded” — which is the moment most teams discover they had a computed all along. When a watcher is right, the one choice left is when it runs:

flushWhen the callback runsUse it for
'pre' (default)Queued; runs before the component’s DOM is updated, so the DOM it sees is the old oneAlmost everything: requests, storage, logging
'post'Queued; runs after the DOM is updated (watchPostEffect is the shorthand)Measuring an element, moving focus, handing the DOM to a third-party library
'sync'Immediately on every write, with no batching: ten writes, ten runsRarely anything in application code

Templates the compiler can read

A .vue file holds three blocks: the template, the script and the styles. The argument for keeping them in one file is that they change together — editing a component means editing all three. The styles block takes a scoped attribute, and it is a genuinely useful default: Vue adds a generated attribute to the component’s elements and limits every selector to it, so a rule cannot leak into another component. That is the locality CSS methodologies are usually adopted to provide, supplied by the compiler.

The template itself is HTML with directives — attributes starting with v- that Vue compiles into code. Six of them carry most of the work, and each has one trap worth knowing before it bites:

DirectiveWhat it doesThe trap
v-ifAdds or removes the element; components inside are created and destroyed each timeA heavy panel toggled often — every toggle builds it again
v-showKeeps the element and toggles its CSS displaySomething expensive that is rarely shown — it is rendered on the first load anyway
v-for + :keyRenders a list; the key is each item’s identity between rendersThe index as the key: state and focus stay with the position when the data moves
v-if with v-forOn one element, Vue 3 evaluates v-if first, so the condition cannot see the loop variableCode written for Vue 2, where the order was the reverse — filter in a computed instead
v-modelTwo-way binding: a value goes down, an update event comes back upAn object passed through it and changed in place — the parent changes without the event
v-htmlInserts a string as raw HTML, with no escaping at allAnything a user wrote: it is the one door to cross-site scripting that Vue leaves open

The template has a second job, and it is the reason Vue kept templates when much of the front end moved to . Like React, Vue renders through a virtual DOM — a lightweight copy of the page kept in JavaScript: on each update it builds a new copy, compares it with the previous one, and changes only the real DOM nodes that differ. Comparing is the cost. A template is a restricted language, so the compiler can read it and know, before the page ever runs, which parts can change and which cannot. Vue calls the result a compiler-informed virtual DOM. Static parts are created once and reused. Each dynamic node carries a patch flag saying what about it can change — its text, its class, one attribute. And the dynamic nodes inside a block are collected into one flat list, so an update visits them directly instead of walking the whole tree.

A template is a restricted language, so Vue’s compiler can tell before the page runs which nodes can change. A virtual DOM with no such hints has to compare every node on every update. Vue 3 marks each dynamic node with a patch flag and collects the dynamic nodes of a block in one flat list, so an update visits two nodes here instead of ten. Vapor Mode, in the Vue 3.6 release candidate, drops virtual nodes altogether and compiles the template into direct writes to those two DOM nodes. The tree is illustrative.

Vapor Mode takes the same idea to its end. It compiles a component straight into DOM operations, with no virtual DOM at all, and in third-party benchmarks it reaches the same level as Solid and Svelte 5. It is part of Vue 3.6, which has been a release candidate since July 2026 and is not yet stable. It is opt-in per component, with <script setup vapor>; it supports the Composition API only, not the Options API; and the Vue team recommends it for now for a single performance-sensitive page in an existing app, or for a small new app built entirely in it.

Composition API, Options API and composables

Vue has two ways to write a component’s script, and both are supported. The Options API, which Vue started with, groups code by kind: data, computed, methods, watch. The Composition API, added in Vue 3, lets you group it by feature, as the functions in the table at the top of the page.

Composition API

  • Code grouped by feature, not by kind of option
  • Logic moves out into plain functions — composables
  • Types infer on their own, so needs no help
  • The default for new code, and the only one Vapor Mode compiles

Options API

  • Code grouped into data, computed, methods, watch
  • Reuse through mixins, whose origin is hard to trace
  • An easier first hour; a harder tenth component
  • Supported, and common in existing code

The difference shows at size rather than at the start. In a component with three concerns, the Options API spreads each of them across four sections, so understanding “how does search work here” means reading four places and holding them together. The Composition API lets the three concerns sit as three blocks — and lets any of them move out into a composable that another component imports. A composable is simply a function whose name starts with use and which calls Vue’s reactivity functions inside: useSearch(), useMouse(), useFetch(url).

Composables replaced mixins, and Vue’s documentation names the three reasons. With mixins it was unclear which mixin a property came from; two mixins could collide on the same name; and mixins talked to each other through shared properties nobody declared. A composable has none of those problems, because it is an ordinary function call: what it returns is visible where you call it, and you can rename it there.

Vue 2 to Vue 3, and what comes in the box

Vue 3 was released in September 2020, and it was not an upgrade in the usual sense. The reactivity system was rebuilt on proxies, several Vue 2 features were removed, and the official tools around the framework were replaced one by one. A great deal of material online still describes Vue 2, so it is worth knowing which half of a tutorial you are reading:

JobVue 2Vue 3
Tracking changesObject.defineProperty: cannot see an added property or an array item set by index; Vue.set covers the gapProxy: sees both, and Vue.set is gone
Writing a componentOptions APIComposition API with <script setup>; Options API still supported
Shared stateVuexPinia — Vuex is in maintenance mode
Starting a projectVue CLI, on webpacknpm create vue, on Vite
Editor supportVeturVue – Official (formerly Volar)
Events between unrelated componentsAn event bus on $on, $off, $onceRemoved; props and events, a store, or a small emitter library
Formatting in templatesFilters: {{ price | currency }}Removed; a function or a computed
BrowsersIncluding Internet Explorer 11Modern browsers only: a proxy cannot be imitated in older JavaScript

Around the core, Vue sits between React and Angular in how much it decides for you. It is not a library that leaves everything open, and not a framework that supplies the whole architecture. It has a small set of official packages, and because they are official, the ecosystem gathers around them rather than splintering: Vue Router for routing; Pinia for shared state, small enough that using it for one shared value is not overkill; Nuxt as the framework for server rendering, routing conventions and deployment; and , the build tool Vue’s author started in 2020 and which much of the JavaScript world now uses whatever its framework. The reactivity system is even published on its own, as @vue/reactivity, and works with no components at all — a fair signal of where the framework’s centre of gravity is.

Vue against React

QuestionVueReact
How are dependencies known?Seen as they are readListed by you, checked by a lint rule
What runs again on a change?Only the effects that read the valueThe component and everything below it
How is the view written?A template the compiler analysesJSX — ordinary JavaScript
Who decides the architecture?Official router and storeYou do, or the framework you pick
Where does a value get lost?Copied out of the proxyChanged in place instead of replaced

Neither column is a verdict. The template lets the compiler know more, so Vue can skip at run time work React has to do — though React Compiler now closes part of that gap from the other side. JSX is just JavaScript, so anything the language can express, the view can express. Choose on the two things that really differ for a team: whether you want conventions supplied, and which pool of experienced developers you can hire from.

Five cases, and what each one was missing

Each case below is documented by the Vue team or by the company it happened to. Four are about Vue’s own history, because the framework’s biggest lessons were learned in public, and one is about what that history costs a large application that has to live through it.

CaseWhat happenedWhat was missing
Vue 2’s blind spots, 2016–2020A property added later, or an array item set by index, did not update the screen; Vue.set was the workaroundA tracking mechanism that could see every write — the reason for proxies in Vue 3
Vue 3 as the default, Feb 2022The core shipped in September 2020, but became the default only seventeen months laterA router, a store, tooling and devtools ready on the same day as the core
Reactivity Transform, 2023An experimental syntax that removed .value was withdrawn; teams that adopted it had to migrate backA rule against experimental compiler features in production code
Vue 2 end of life, Dec 2023On 31 December 2023 Vue 2 stopped receiving fixes of any kind, security fixes includedA migration scheduled before the date rather than after it
GitLab, 2026Nearly three years after that date, most of GitLab’s 579 front-end entry points still run on Vue 2A code base free of the patterns the next major version removes

The first case is where Vue 3’s design comes from. Vue 2 made data reactive by converting each existing property into a getter and a setter with Object.defineProperty, the only tool JavaScript had in 2014 that could run everywhere. The tool could only convert properties that already existed. A property added after the object was made reactive, or an array item assigned by index, went around the setters, and the screen did not change. The Vue 2 guide listed these cases under “Change Detection Caveats”, and Vue.set existed to work around them. Vue 3 moved to proxies, which intercept every read and write, including ones to properties that did not exist yet. The price was Internet Explorer 11: a proxy cannot be imitated in older JavaScript, so Vue 3 does not run there.

The second is a lesson about what a release is. Vue 3.0 came out on 18 September 2020, and for seventeen months npm install vue still gave you Vue 2. The Vue team explained why when it finally switched, on 7 February 2022: “When Vue 3 core was ready, most other parts of the framework were either in beta or still awaiting update.” A team that moved early had a faster core and no stable router, store, editor support or devtools to go with it. The switch came when Vite, Pinia, Volar and the new devtools were ready together.

The fourth case has a date on it. Vue 2 reached end of life on 31 December 2023, and the official page says what that means without softening it: Vue 2 “no longer receives new features, updates, or fixes”. Version 2.7.16 is the last one there will ever be. The code keeps working and stays on every CDN and package registry, so nothing breaks on 1 January — which is precisely the danger. The next vulnerability found in Vue 2 has no official fix, and the page’s own advice to organisations that cannot move is to buy extended support from a third party and to tell their customers and their security officer what the plan is.

Where it degrades

Vue’s reputation for being approachable is earned and slightly misleading. The first day really is easier: a template looks like HTML, reactivity does what you expect, and there is no dependency array to get wrong. The part that needs care arrives later and is specific — knowing exactly where a value stays tracked and where it is copied out — because every failure in that part is silent. These are the shapes it takes.

  • Destructuring or copying a tracked value, which silently detaches it — rows 1 to 5.
  • A watcher that assigns to other state: a computed that is empty at first and stale in between.
  • Watchers that fetch without onWatcherCleanup, so a slow old answer overwrites a fast new one.
  • Deeply reactive large data, where the proxy work is real and shallowRef was the answer.
  • Mutating a prop, or an object received through v-model, which breaks the one-way flow.
  • provide / inject used as a global variable, which hides where a value comes from.
  • v-html on anything a user wrote.
  • Experimental syntax in production code, which the next minor version is allowed to remove.
  • A code base still on Vue 2, which has had no fixes of any kind since 31 December 2023.

There is also an honest limit, built into the mechanism the page opened with. Vue guarantees that what reads a value re-runs when the value changes. It says nothing about whether the value is right, and it cannot track what it does not own: state that lives in a third-party library, a Web API or a closure is invisible until you bring it inside a ref. The hiring pool is the other limit, and it is not a technical one — in many markets it is smaller than React’s, which matters more than any benchmark when the team will change hands.

The practical check takes a minute. Open Vue DevTools and look at what re-rendered after the one action the screen exists for. When something updates that should not, or fails to update when it should, add onRenderTriggered to the component: in development it tells you exactly which read of which property started each render. A value that never appears there was never tracked — and the table in section two says why.

When to use it

Use it when

  • You want official answers for routing, state and the build rather than a comparison exercise.
  • The team includes people who write more markup than JavaScript — templates fit that shape.
  • You want updates that reach only what changed, without tuning what re-renders by hand.
  • Scoped styles by default would replace a CSS methodology you would otherwise have to adopt.

Avoid it when

  • The local hiring market is overwhelmingly React and the team will change hands.
  • The project depends on libraries that exist today only for React.
  • A page with almost no state — the reactive machinery is overhead you would never use.
  • Nobody will learn where a value stops being tracked, which makes the silent failures expensive.

Sources and method

What this page stands on

  • 12 sources
  • Checked October 13, 2026

Researched with Claude. Finding and cross-checking the primary sources was done with Claude (Anthropic). Selection, writing and fact-checking: the Anatomy of IT Product editors.

Found this useful?

Share it with someone who is working on the same problem.