reactive
reactive returns a proxy you use without .value, and it loses reactivity if you destructure or reassign it.
function reactive<T extends object>(target: T): UnwrapNestedRefs<T>reactive takes an object and returns a proxy of it: a stand-in that looks identical but reports every property read and every property write to Vue. reactive({ count: 0 }) gives you state you use without .value — state.count++ is enough.
What it can hold
Objects only: plain objects, arrays, Map and Set. A primitive cannot be proxied, so a single number or string belongs in a ref. The conversion is deep, and because it is proxy-based, adding a property, deleting one and writing an array index are all tracked in Vue 3.
The two surprises
Reactivity lives on the proxy, not on the values inside it. const { count } = state copies a plain number, so count++ changes nothing the template sees. toRefs(state) gives you refs that read and write through to the object instead.
And the variable is yours, not Vue's. state = reactive({ count: 1 }) rebinds your variable; the render and every watcher keep reading the old proxy. If the whole object has to be replaced, hold it in a ref and assign to .value.
What trips people up
The proxy is not the object you passed in. Keep one reference and use it everywhere. Writes made to the original object are reflected when you read the proxy, but they trigger nothing.
Example
<!-- src/components/FilterBar.vue -->
<script setup lang="ts">
import { reactive } from 'vue'
interface Filters {
query: string
onlyOpen: boolean
}
const filters = reactive<Filters>({ query: '', onlyOpen: false })
function onQuery(event: Event): void {
filters.query = (event.target as HTMLInputElement).value
}
function toggleOpen(): void {
filters.onlyOpen = !filters.onlyOpen
}
</script>
<template>
<label>
Search
<input type="text" :value="filters.query" @input="onQuery" />
</label>
<button type="button" @click="toggleOpen">
{{ filters.onlyOpen ? 'showing open only' : 'showing everything' }}
</button>
<p>query: {{ filters.query }}</p>
</template>
<style scoped>
label {
display: block;
}
</style>When to use it
- Group the fields of one form or filter bar into a single state object.
- Mutate deeply nested data in place without writing .value at every level.
- Model a cart or settings object whose shape is fixed and never swapped out.
More examples
Destructuring loses the connection; toRefs restores it
The single most common reactive bug, with its fix next to it. toRefs(state) converts every enumerable property into a ref pointing at that property, so destructuring is safe again at the cost of .value. Use it when you want short names but must keep the link.
// src/lib/counter-state.ts
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, label: 'hits' })
// A plain copy. Changing it never touches state.
let { count } = state
count++
console.log(state.count)
// Output: 0
// toRefs returns refs wired back to the same properties.
const { count: countRef } = toRefs(state)
countRef.value++
console.log(state.count)
// Output: 1Replacing the whole object
firstProxy stands in for the render function and every watcher that already read the old proxy: they are still pointed at id 1. Whenever state arrives as a whole new object, store it in a ref and assign to .value.
// src/lib/swap.ts
import { reactive, ref } from 'vue'
let viaReactive = reactive({ id: 1 })
const firstProxy = viaReactive
viaReactive = reactive({ id: 2 })
console.log(firstProxy.id)
// Output: 1
// A ref is one stable box, so the contents can be swapped.
const viaRef = ref({ id: 1 })
viaRef.value = { id: 2 }
console.log(viaRef.value.id)
// Output: 2Collections and nested objects
Vue 3 proxies arrays and the collection types, so index writes, length changes, push, Map.set and Set.add all trigger updates without special helpers. This is the case where reactive earns its keep: deeply nested state you mutate in place and never reassign.
// src/lib/cart.ts
import { reactive } from 'vue'
const cart = reactive({
lines: [{ sku: 'A1', qty: 1 }],
seen: new Set<string>()
})
cart.lines[0].qty = 3 // nested object write: tracked
cart.lines.push({ sku: 'B2', qty: 1 }) // array mutation: tracked
cart.seen.add('A1') // Set mutation: tracked
console.log(cart.lines.length, cart.seen.has('A1'))
// Output: 2 true
Discussion