Vue 3 Reactivity Architecture: Choosing Between ref and reactive for Component State
Vue 3's ref and reactive APIs solve different reactivity problems. Learn how to choose between them, verify dependency tracking, and avoid lost proxies.
11 Sept 2025, 19:34 UTC

The Problem: State Shape Dictates Reactivity
Vue 3's Composition API gives you two primary tools for local component state: ref and reactive. Choosing the wrong one does not produce a compile error—it produces a silent failure where the template stops updating. The decision hinges on whether your state is a primitive value or a structured object.
Requirements for Local Component State
Before selecting an API, define what your component needs from state:
- Automatic re-render: When a value changes, the template must reflect it without manual DOM calls.
- Minimal boilerplate: The smallest abstraction that satisfies the first requirement.
- Preserved identity: The reactive reference must survive prop passing and composable returns without losing its proxy wrapper.
Smallest Suitable Design: Match the Wrapper to the Data
Vue's reactivity system uses JavaScript Proxy objects to intercept property reads and writes. The dependency collector records which properties a component's render function accesses. When a tracked property changes, Vue knows which components to re-render.
Primitives require ref
A primitive—string, number, boolean, null, undefined, symbol, or bigint—is passed by value, not by reference. Vue cannot attach a proxy to a standalone primitive. ref solves this by wrapping the value in an object with a .value property. The proxy lives on that wrapper.
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
In the template, Vue automatically unwraps ref, so you reference count directly. In JavaScript, you must use count.value.
Objects use reactive
When state is already an object, reactive creates a deeply reactive proxy. Every nested property is tracked. You mutate properties directly without a wrapper.
<script setup>
import { reactive } from 'vue'
const form = reactive({
email: '',
submitted: false
})
function submit() {
form.submitted = true
}
</script>
<template>
<input v-model="form.email" />
<p>{{ form.submitted ? 'Sent' : 'Draft' }}</p>
</template>
Because reactive proxies the object itself, you never write form.value.email. The dependency collector tracks form.email and form.submitted independently.
Comparison
| State type | API | Mutation syntax | Template access |
|---|---|---|---|
| Primitive | ref | state.value = x | {{ state }} |
| Object / Array | reactive | state.prop = x | {{ state.prop }} |
Trust and Data Boundaries: Where the Proxy Lives
The reactive proxy is not the plain object. If you extract a nested object and pass it outside the component—into a non-reactive cache, a Web Worker, or a plain utility function—you lose the proxy. Mutations made on the extracted copy will not notify Vue.
Example of a broken boundary:
<script setup>
import { reactive } from 'vue'
const state = reactive({ items: [] })
// BAD: items is now a plain array reference
const plainItems = state.items
plainItems.push('new') // Vue will not re-render
</script>
The fix is to keep mutations inside the component or composable that owns the reactive state, or to pass the proxy itself and let consumers call methods that mutate it.
Operational Checks: Confirming Dependency Tracking
You can verify reactivity without writing tests by using Vue DevTools. Open the Reactivity tab and inspect the component's state. The following checks assume Vue 3.4+ and a Vite-based project.
- Check
refunwrapping: In a component usingref(0), click the increment button. The DevTools Reactivity pane should show theRefwrapper'svaluechange, and the DOM should update in the same tick. - Check
reactivedepth: Withreactive({ count: 0 }), incrementobj.countin the console. The template should update without calling$setor reassigning the whole object. - Check primitive failure: Assign a primitive directly—
let count = 0—and mutate it. The template should not update, confirming that primitives needref.
Run the project with npm run dev (or pnpm dev) in the project root. No elevated permissions are required for local development.
Failure Modes
Lost proxy on destructuring
Destructuring a reactive object extracts plain values:
<script setup>
import { reactive } from 'vue'
const user = reactive({ name: 'Ada', role: 'admin' })
// BAD: name and role are now plain strings
const { name, role } = user
</script>
Use toRefs if you need destructured reactive references.
External library mutation
Some charting or mapping libraries mutate data structures directly through internal references. If they receive a plain copy rather than the proxy, Vue's dependency collector never fires. The symptom is a UI that updates only when an unrelated state change forces a re-render.
Standalone primitive with reactive
Passing a primitive to reactive returns the primitive unchanged. No proxy is created, and no reactivity is established.
Conditions That Change the Design
- Shared state across components: When multiple components need the same state, lift it into a composable using
reforreactiveand export the proxy. Do not export a plain snapshot. - Large immutable datasets: If you replace entire objects frequently rather than mutating properties,
refwith a new object reference may be simpler than deepreactivetracking. - Performance-sensitive rendering: Deeply reactive objects track every nested access. For very large trees where only top-level identity matters, consider
shallowReactiveorshallowRef. - Integration with non-Vue code: If an external store owns the data, treat that store as the source of truth and map its state into component-local
refor computed values rather than trying to make the external object reactive.
Practical Verification
After implementing either pattern, open the browser DevTools Reactivity tab, trigger the mutation, and confirm the component's render effect appears in the timeline. If the DOM does not update, check whether the value being mutated is a proxy or a detached plain copy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.