Reactivity Fundamentals

This section documents the current Vue 3.x release line as published at the official Vue.js documentation, which is the reference these pages are written and verified against. No specific patch version is pinned. The Composition API with <script setup> is the authoring style used throughout; the Options API is shown only as a contrast.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Reactive state is state that Vue watches: when it changes, every component and computed value that read it re-runs. The Composition API declares this state with two functions, ref() and reactive(). This page covers both and the rules around them, then a final section on the Proxy machinery that makes it work, drawn from Reactivity Fundamentals, the core API and the utilities API.

ref()

ref() wraps any value — primitive or object — in a reactive container with a single .value property.

<script setup>
import { ref } from 'vue'

const count = ref(0)
const user = ref({ name: 'Ada', roles: ['admin'] })

function bump() {
  count.value++            // .value in script
  user.value.name = 'Bo'   // deep: nested mutation is tracked too
}
</script>

<template>
  <!-- no .value in the template: top-level refs are auto-unwrapped -->
  <button @click="bump">{{ count }} / {{ user.name }}</button>
</template>

.value vs. template auto-unwrap. In JavaScript you always read and write count.value. In the template, a ref used as a top-level property is unwrapped automatically, so \{\{ count }} works. The unwrap is only shallow — \{\{ object.someRef }} is not unwrapped, and \{\{ someRef + 1 }} fails because someRef is resolved before the +. Give such refs a const alias or wrap them in a reactive object.

Deep reactivity. A ref holding an object makes the whole object graph reactive via reactive() internally. Use shallowRef() (below) to opt out.

reactive()

reactive() returns a Proxy of an object; reads and writes on the proxy are intercepted.

<script setup>
import { reactive } from 'vue'

const state = reactive({ count: 0, items: [] })
state.count++              // no .value -- it is a proxy, not a wrapper
</script>

Its limits shape when you reach for it:

  • Objects only. reactive() does nothing for primitives — use ref() for a number or string.

  • Keep the reference. Replacing the variable (state = reactive(\{ …​ })) drops the link to the proxy the template holds. Mutate the existing object instead.

  • Destructuring loses reactivity. const \{ count } = state copies a plain number. Convert the properties to refs first with toRefs(), or grab one with toRef():

import { reactive, toRefs, toRef } from 'vue'

const state = reactive({ x: 1, y: 2 })

const { x, y } = toRefs(state)   // x, y are refs linked back to state
const xRef = toRef(state, 'x')   // single property as a ref

x.value++                        // updates state.x

ref vs. reactive: which to use

Prefer ref() as the default. It works for every type, survives destructuring and function boundaries when passed as a whole, and makes the reactive boundary visible through .value. Use reactive() for a tightly scoped group of related fields where the .value noise outweighs its caveats. Do not mix a reactive wrapper around ref`s unless you need the ergonomics — nested refs inside a `reactive object are unwrapped, but refs inside a reactive array or Map are not.

Options API contrast

The Options API has no ref / reactive calls: everything returned from data() is passed through reactive() for you.

<script>
export default {
  data() {
    return { count: 0, user: { name: 'Ada' } }  // deeply reactive, like reactive(...)
  }
}
</script>

DOM update timing and nextTick()

State changes do not touch the DOM synchronously. Vue buffers them and flushes once per tick, so multiple mutations cost one re-render. To read the updated DOM, await nextTick():

<script setup>
import { ref, nextTick } from 'vue'

const count = ref(0)
const el = ref(null)

async function bump() {
  count.value++
  console.log(el.value.textContent)   // still the old text

  await nextTick()
  console.log(el.value.textContent)   // now updated
}
</script>

<template>
  <p ref="el">{{ count }}</p>
</template>

<script setup> bindings and the template

Every top-level binding declared in <script setup> — imported names, const / let, function declarations — is exposed to the template with no return. Reactive state stays reactive; a plain value is just a constant the template can read. This is why the examples above never list what they expose.

How reactivity works

This section corresponds to Reactivity in Depth and the advanced API.

Track on read, trigger on write

reactive() uses a Proxy to intercept property access; ref() uses get value() / set value() accessors. A stripped-down ref shows the shape:

function ref(value) {
  const r = {
    get value() {
      track(r, 'value')      // record: the running effect depends on this
      return value
    },
    set value(newValue) {
      value = newValue
      trigger(r, 'value')    // re-run every effect that read it
    }
  }
  return r
}

An effect is a function whose reactive reads are tracked — a computed getter, a watch / watchEffect callback, and above all a component’s render effect (its render function). While an effect runs it is the "active" effect; every reactive value it reads adds it to that value’s dependency set. A later write to that value looks the set up and re-runs only those effects.

A component render effect reads ref and reactive state; each key records the effect in a dependency map, and a later mutation looks up that map to re-run only the dependent effects

Reactivity caveats

  • Adding properties — a Proxy tracks new root-level keys on a reactive object (unlike Vue 2). Refs sidestep the question by holding the whole object in .value.

  • Replacing an array or object — state.list = […​] is tracked; reassigning the top-level reactive variable itself is not.

  • Index and length writes — arr[5] = x and arr.length = 0 on a reactive array are tracked.

  • Collections — Map and Set are supported, but a ref stored as a Map value is not unwrapped.

  • Destructuring — pulls plain values out; use toRefs() / toRef().

Advanced reactivity APIs

import {
  shallowRef, triggerRef, customRef,
  shallowReactive, shallowReadonly,
  toRaw, markRaw,
  effectScope, onScopeDispose,
} from 'vue'

// Shallow: only the top level is reactive
const big = shallowRef({ rows: [] })
big.value.rows.push(1)   // NOT tracked
big.value = { rows: [] } // tracked (assignment to .value)
triggerRef(big)          // force dependents to run after a deep mutation

const opts = shallowReactive({ nested: { a: 1 } }) // opts.nested is a plain object
const frozen = shallowReadonly({ a: 1 })           // top-level writes warn, nested allowed

// Escape hatches
const original = toRaw(reactive({ a: 1 }))  // the un-proxied object
const external = markRaw({ sdk: thirdParty }) // reactive() / ref() will never proxy this

// customRef: full control over track / trigger -- here, a debounced ref
function debouncedRef(value, delay = 200) {
  let timer
  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(next) {
      clearTimeout(timer)
      timer = setTimeout(() => {
        value = next
        trigger()
      }, delay)
    },
  }))
}

// effectScope: own a group of effects and dispose them together
const scope = effectScope()
scope.run(() => {
  watchEffect(() => {/* ... */})
  onScopeDispose(() => {/* cleanup when the scope stops */})
})
scope.stop()  // stops every effect created inside run()

Integrating external and immutable state

For a large object you never mutate in place — an immutable data structure, a snapshot from another store — hold it in shallowRef and assign a new value on each change, calling triggerRef only if you mutate the same reference deeply. Wrap third-party class instances in markRaw so Vue does not deep-proxy them. State managers such as Pinia are built on exactly these primitives.

See also