ref and reactive: Internal Differences, Auto-Unwrapping Boundaries and Selection Guide
Chapter 6: ref and reactive — Internal Differences, Auto-Unwrapping Boundaries, and Selection Guidelines
Vue 3 ships with two reactive APIs coexisting, and a beginner's first question is usually "why not merge them into one?" The answer: they solve problems at different levels.
reactivesolves "how to track changes to object properties."refsolves "how to make primitive types (numbers, strings) trackable too." These two problems have different technical constraints.
Core Question: Why does ref require .value? In which scenarios does auto-unwrapping activate? How do you make the right technical choice between the two?
After reading this chapter, you will understand:
- The implementation differences between
ref()'sRefImplclass andreactive()'s Proxy - The three scenarios where auto-unwrapping activates and the two exceptions, with technical reasons for each
- How
toRefs()/toRef()works and when it must be used - A clear decision tree: given a specific scenario, should you use
reforreactive
Level 1 · What You Need to Know (1-3 Years Experience)
6.1 Why Are Two APIs Needed?
JavaScript values fall into two categories:
- Primitives: number, string, boolean, null, undefined, Symbol, BigInt
- Reference types: plain objects, arrays, Map, Set, functions, etc.
These two categories behave fundamentally differently in JavaScript:
// Reference types: pass by reference, changes can be tracked
const obj = { count: 0 };
const anotherRef = obj; // anotherRef points to the same object
obj.count = 1;
console.log(anotherRef.count); // 1, because it's the same object
// Primitives: pass by value (copy), cannot track changes
let num = 0;
let copy = num; // copy is an independent copy of 0
num = 1;
console.log(copy); // 0, changes to num don't affect copy
Proxy can only proxy objects, because Proxy's first argument must be an object:
const proxy = new Proxy(0, {}); // TypeError: Cannot create proxy with a non-object as target
This is the fundamental reason ref exists: wrapping a primitive in an object, then using getter/setter to track access to that wrapper object.
// Core idea of ref
const count = ref(0);
// Equivalent to creating a special object:
// { value: 0 } ← but this object's .value has get/set interception
count.value; // Triggers get, calls track
count.value = 1; // Triggers set, calls trigger
6.2 Internal Implementation of ref(): RefImpl
ref() internally creates a RefImpl object:
class RefImpl<T> {
private _value: T; // Actually stored value
private _rawValue: T; // Raw value (for comparison, avoids reactive wrapper interference)
public dep?: Dep; // dep Set stored directly on the instance (no three-level Map needed)
public readonly __v_isRef = true; // Marks this as a ref
constructor(value: T, public readonly __v_isShallow: boolean) {
this._rawValue = toRaw(value);
// If object, convert to reactive; if primitive, store directly
this._value = __v_isShallow ? value : toReactive(value);
}
get value() {
trackRefValue(this); // Track dependency
return this._value;
}
set value(newVal) {
const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal);
newVal = useDirectValue ? newVal : toRaw(newVal);
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
this._value = useDirectValue ? newVal : toReactive(newVal);
triggerRefValue(this, newVal); // Trigger update
}
}
}
Notice that ref's dep is stored directly on the RefImpl instance (this.dep), not in the global targetMap. This is because ref has only one "key" (.value), so the extra Map level isn't needed.
6.3 Internal Implementation of reactive(): Proxy
// reactive core: returns a Proxy
export function reactive<T extends object>(target: T): UnwrapNestedRefs<T> {
// If already a readonly object, return directly
if (isReadonly(target)) {
return target as any;
}
return createReactiveObject(
target,
false,
mutableHandlers, // For plain objects
mutableCollectionHandlers, // For Map/Set etc.
reactiveMap // Cache
);
}
The fundamental difference between reactive and ref:
| Feature | ref |
reactive |
|---|---|---|
| Implementation | RefImpl class (getter/setter) | Proxy |
| Applicable types | Primitives + objects | Objects only |
| Access pattern | .value (primitives), .value.prop (objects) |
Direct .prop |
| In templates | Auto-unwrapped, no .value needed |
Used directly |
| Destructuring | Safe (ref itself is an object, destructured result is still a ref) | Unsafe (destructures out primitive values, loses reactivity) |
6.4 Why Does ref Require .value?
// Suppose JavaScript allowed us to do this (it doesn't)
let count = 0;
// Assignment to count variable triggers reactive updates
// Problem: variable assignment is a language-level operation
// No API can intercept "count = 1"
// Because the assignment target is a variable (lexical binding), not an object
ref's .value design is an elegant engineering solution constrained by the language:
// JavaScript's constraint:
// Cannot intercept: count = 1 ← plain assignment
// CAN intercept: count.value = 1 ← object property assignment, interceptable with getter/setter
const count = ref(0);
// count itself is an object (RefImpl), it doesn't change
// count.value is a property access, which can be intercepted by getter/setter
count.value++; // Equivalent to: count.value = count.value + 1
// Triggers get first (track), then set (trigger)
This also explains why ref objects themselves should be const (you shouldn't reassign count = ref(1)) — instead, modify count.value.
6.5 The Three Scenarios Where Auto-Unwrapping Activates
Scenario 1: Top-level refs in templates auto-unwrap
<script setup>
import { ref } from 'vue';
const count = ref(0);
const user = ref({ name: 'Vue' });
</script>
<template>
<!-- Auto-unwrapping: no .value needed -->
<p>{{ count }}</p> <!-- Equivalent to {{ count.value }} -->
<p>{{ user.name }}</p> <!-- Equivalent to {{ user.value.name }} -->
<!-- Note: only top-level refs auto-unwrap -->
</template>
Scenario 2: refs nested inside reactive objects auto-unwrap
import { ref, reactive } from 'vue';
const count = ref(0);
const state = reactive({ count }); // Nest ref inside reactive object
// state.count auto-unwraps: no .value needed
console.log(state.count); // 0 (not a RefImpl, the direct value)
state.count = 1; // Automatically updates count.value (equivalent to count.value = 1)
console.log(count.value); // 1 (synchronized!)
Scenario 3: ref used as a watch source
// Inside watchEffect, refs need .value
watchEffect(() => {
console.log(count.value); // Explicit .value required
});
// But as watch's source, you can pass the ref directly
watch(count, (newVal) => {
console.log(newVal); // newVal is already the unwrapped value
});
6.6 Auto-Unwrapping Boundary Traps
Exception 1: refs inside arrays do NOT auto-unwrap
import { ref, reactive } from 'vue';
const count = ref(0);
const arr = reactive([count]); // ref inside an array
// No auto-unwrap inside arrays:
console.log(arr[0]); // RefImpl, not 0
console.log(arr[0].value); // 0 (must access .value manually)
// Compare to reactive object:
const obj = reactive({ count });
console.log(obj.count); // 0 (auto-unwrapped)
Exception 2: refs inside Maps do NOT auto-unwrap
import { ref, reactive } from 'vue';
const count = ref(0);
const map = reactive(new Map([['count', count]]));
// No auto-unwrap inside Maps:
console.log(map.get('count')); // RefImpl, not 0
console.log(map.get('count').value); // 0 (must access .value manually)
The design reason for these two exceptions is performance and consistency: in arrays and Maps, auto-unwrapping would make it impossible at runtime to distinguish "stored a ref" from "stored a plain value," breaking the semantic integrity of those data structures.
6.7 The Role of toRefs() and toRef()
toRefs(): convert every property of a reactive object to a ref
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: 'Vue' });
// Destructuring a reactive object: loses reactivity
const { count } = state; // count is a plain number 0, non-reactive
// Using toRefs then destructuring: preserves reactivity
const { count, name } = toRefs(state);
// count is a ref, count.value === state.count
// name is a ref, name.value === state.name
// Two-way binding: modifying either side syncs to the other
count.value = 99;
console.log(state.count); // 99
state.count = 100;
console.log(count.value); // 100
toRef(): convert a single property of a reactive object to a ref
import { reactive, toRef } from 'vue';
const state = reactive({ count: 0 });
const countRef = toRef(state, 'count');
// countRef is a ref linked to state.count
countRef.value = 99;
console.log(state.count); // 99
An important use of toRef is converting optional properties to refs, even if the property doesn't exist:
const state = reactive({});
const undeclaredRef = toRef(state, 'undeclared');
// undeclaredRef.value === undefined (no error thrown)
// This is very useful when handling component props
6.8 The Selection Decision Tree
ref or reactive?
┌─────────────────────────────────────────────────────────────┐
│ Is it a primitive type (number/string/boolean)? │
└─────────────────────────────────────────────────────────────┘
│ Yes │ No
▼ ▼
ref() Do you need to destructure?
│ Yes │ No
▼ ▼
ref() + toRefs() Can use reactive()
or just use ref() but ref() also works
(multiple refs instead of object)
Special cases:
├── Passing reactive data to functions/composables? → Use ref (can pass the ref object itself)
├── Fetching JSON data from server? → Use ref (initial value is null, then assign whole object)
├── Need to replace the entire object? → Use ref (reactive can't be replaced wholesale)
└── Large fixed-structure state (store-level)? → Can use reactive
Level 2 · How It Works Under the Hood (3-5 Years Experience)
6.9 Performance Differences Between RefImpl and Proxy
ref and reactive have a few key performance differences:
Tracking overhead:
ref's tracking path (short):
count.value read
→ RefImpl.get()
→ trackRefValue(this)
→ track(this, 'value')
→ dep.add(activeEffect) // dep is directly on RefImpl
reactive's tracking path (longer):
state.count read
→ Proxy get trap
→ Reflect.get(target, 'count', receiver)
→ track(target, 'count')
→ targetMap.get(target).get('count').add(activeEffect) // three-level lookup
ref's tracking path is shorter because dep is directly on the RefImpl instance, without the three-level global Map lookup. But this difference is typically only visible in microbenchmarks — in real applications, the performance difference is negligible.
Memory usage:
ref(0)creates one RefImpl object (approximately 5 properties)reactive({ count: 0 })creates one Proxy object + WeakMap entry
For a single primitive value, ref is slightly lighter. For objects with many properties, reactive is more efficient (one Proxy tracks multiple properties, while multiple ref calls need multiple RefImpl objects).
6.10 The Wholesale Replacement Problem with reactive
import { reactive, ref } from 'vue';
// Problem: reactive objects cannot be replaced wholesale
let state = reactive({ count: 0 });
// Wrong: this breaks reactivity
state = reactive({ count: 1 }); // state now points to a completely new reactive object
// But wherever previously held a reference to state (templates, other variables)
// still points to the OLD reactive object
// The view will NOT update!
// Correct approach 1: modify properties, don't replace wholesale
Object.assign(state, { count: 1 });
// Correct approach 2: wrap the reactive object in a ref
const state = ref({ count: 0 });
state.value = { count: 1 }; // Works! ref's setter handles making the new value reactive
The wholesale replacement problem with reactive:
const state = reactive({ count: 0 });
The component template holds a reference to this Proxy object:
template → state (Proxy A)
state = reactive({ count: 1 });
The state variable now points to new Proxy B, but:
template still holds reference to Proxy A → view does NOT update!
Compare with ref:
const state = ref({ count: 0 });
template → state.value (Proxy A for inner object)
state.value = { count: 1 };
RefImpl's setter:
1. New object goes through reactive() to become Proxy B
2. state._value = Proxy B
3. triggerRefValue(state) → notifies all effects that depended on state.value
4. Template re-accesses state.value → gets Proxy B → view updates!
6.11 Internal Implementation of toRefs
// packages/reactivity/src/ref.ts
export function toRefs<T extends object>(object: T): ToRefs<T> {
if (__DEV__ && !isProxy(object)) {
console.warn(`toRefs() expects a reactive object but received a plain one.`)
}
const ret: any = isArray(object) ? new Array((object as any).length) : {}
for (const key in object) {
ret[key] = toRef(object, key)
}
return ret
}
export function toRef<T extends object, K extends keyof T>(
object: T,
key: K,
defaultValue?: T[K]
): ToRef<T[K]> {
const val = object[key]
return isRef(val)
? val // If already a ref, return directly
: (new ObjectRefImpl(object, key, defaultValue) as any)
}
class ObjectRefImpl<T extends object, K extends keyof T> {
public readonly __v_isRef = true
constructor(
private readonly _object: T,
private readonly _key: K,
private readonly _defaultValue?: T[K]
) {}
get value() {
const val = this._object[this._key]
// Accessing the original reactive object's property triggers tracking
return val === undefined ? (this._defaultValue as T[K]) : val
}
set value(newVal) {
// Modifying the original reactive object's property triggers update
this._object[this._key] = newVal
}
}
The elegance of ObjectRefImpl: its get value() internally accesses this._object[this._key], which is a Proxy property access — it triggers reactive tracking. So a ref created via toRef derives its reactivity from the original reactive object, not independently. It's a linked view, not a copy.
6.12 Complete Auto-Unwrapping Implementation Logic
Template auto-unwrapping happens in two places:
Compile-time handling (top-level refs in templates):
// Template compiled output (simplified)
// <template>{{ count }}</template>
// where count is a ref from <script setup>
// Compiled:
function render(ctx) {
return createVNode('span', null, _toDisplayString(ctx.count))
// _toDisplayString calls unref(value), which auto-unwraps
}
Runtime handling (refs nested inside reactive objects):
In the Proxy's get trap:
// packages/reactivity/src/baseHandlers.ts
function createGetter() {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
// ...(other handling)
if (isRef(res)) {
// Note: refs inside arrays don't unwrap (when isIntegerKey(key) is true)
const shouldUnwrap = !targetIsArray || !isIntegerKey(key)
return shouldUnwrap ? res.value : res
// ↑ auto-unwrap ↑ no unwrap (array/Map scenario)
}
// ...
}
}
Level 3 · Design Documents and Source Code (Senior Developers)
6.13 Differences Between shallowRef and shallowReactive
Vue 3 provides two "shallow" variants — understanding them is important for performance optimization:
// shallowRef: only tracks .value replacement, not changes to the inner object
const state = shallowRef({ count: 0 });
watchEffect(() => {
console.log(state.value.count);
});
state.value.count = 1; // Does NOT trigger! Inner object changes not tracked
state.value = { count: 1 }; // Triggers! Whole .value replaced
// shallowReactive: only tracks first-level properties, not nested objects
const state = shallowReactive({ nested: { count: 0 } });
watchEffect(() => {
console.log(state.nested.count);
});
state.nested.count = 1; // Does NOT trigger! Changes inside nested not tracked
state.nested = { count: 1 }; // Triggers! First-level property changed
Applicable scenarios:
shallowRef: storing large immutable data structures (like a complete list fetched from a server) where only wholesale replacement needs trackingshallowReactive: when some nested properties of a state object change frequently but don't need to be reactive (avoiding deep proxy overhead)
6.14 ref's Type System Design
Vue 3's ref has very precise TypeScript type inference:
// Ref<T> type (simplified)
export interface Ref<T = any> {
value: T
[RefSymbol]: true
}
// ref() function overloads
export function ref<T extends Ref>(value: T): T // Already a ref, return as-is
export function ref<T>(value: T): Ref<UnwrapRef<T>> // Wrap the type
export function ref<T = any>(): Ref<T | undefined> // No initial value
// UnwrapRef<T>: recursively unwrap nested refs
type UnwrapRef<T> =
T extends ShallowRef<infer V> ? V
: T extends Ref<infer V> ? UnwrapRefSimple<V>
: UnwrapRefSimple<T>
This type system ensures:
const count = ref(0);
// count: Ref<number>
// count.value: number
const nested = ref({ inner: ref(1) });
// nested: Ref<{ inner: number }> // Note: inner is auto-unwrapped to number, not Ref<number>
// nested.value.inner: number (no .value needed)
// This matches the runtime auto-unwrapping behavior inside reactive
const state = reactive({ count: ref(0) });
// state: { count: number } (TypeScript knows about auto-unwrapping)
// state.count: number
6.15 Advanced Usage: customRef
customRef lets you fully control a ref's tracking and trigger logic:
import { customRef } from 'vue';
// Debounced ref: waits delay milliseconds after modification before triggering update
function useDebouncedRef<T>(value: T, delay = 200) {
let timeout: ReturnType<typeof setTimeout>;
return customRef((track, trigger) => {
return {
get() {
track(); // Manually call track to establish dependency
return value;
},
set(newValue: T) {
clearTimeout(timeout);
timeout = setTimeout(() => {
value = newValue;
trigger(); // Manually call trigger to fire update
}, delay);
}
};
});
}
// Usage
const text = useDebouncedRef('', 300);
// <input v-model="text" /> only triggers reactive updates
// 300ms after the user stops typing
customRef's track and trigger parameters are wrappers around Vue's internal trackRefValue and triggerRefValue, giving developers full control over custom reactive logic.
Level 4 · Edge Cases and Traps (Everyone Should Read)
Trap 1: Nested Refs in Templates Do NOT Auto-Unwrap
<script setup>
import { ref } from 'vue';
const obj = ref({ count: ref(0) }); // Nested ref
</script>
<template>
<!-- Wrong assumption: thinking it auto-unwraps to the bottom level -->
<p>{{ obj.count }}</p> <!-- Actually displays a RefImpl object, not a number! -->
<!-- Correct: must access .value manually -->
<p>{{ obj.value.count.value }}</p> <!-- 0 -->
<!-- Or wrap in reactive to get ref auto-unwrapping inside reactive objects -->
</template>
Root cause: Only refs that are top-level <script setup> bindings auto-unwrap in templates. Refs accessed through an access chain (count in obj.count, where obj.value is a plain object, not a reactive one) are outside the auto-unwrapping logic and will not auto-unwrap.
Trap 2: Destructuring reactive Arrays
import { reactive, watchEffect } from 'vue';
const list = reactive([1, 2, 3]);
// Wrong: destructuring an array also loses reactivity
const [first, second] = list; // first=1, second=2, plain numbers
watchEffect(() => {
console.log(first); // Only prints the initial value 1, doesn't respond to changes
});
list[0] = 99; // first doesn't update
// Correct approach: access list[0] directly, or use a ref array
const list = ref([1, 2, 3]);
// list.value[0] can be tracked
Practical scenario: Destructuring elements of a reactive array in v-for is a common trap:
<template>
<!-- Correct: item is an element of the reactive array, accessed via index, responds to changes -->
<div v-for="item in list" :key="item.id">{{ item.name }}</div>
<!-- If list is a reactive array, item is a snapshot of the element:
if element is a primitive → non-reactive
if element is an object → reactive (due to lazy proxy) -->
</template>
Trap 3: Detecting Refs with isRef
import { ref, isRef } from 'vue';
const count = ref(0);
console.log(isRef(count)); // true
// Common mistake: using typeof or instanceof for detection
console.log(typeof count); // 'object' (doesn't distinguish refs from plain objects)
console.log(count instanceof RefImpl); // Error! RefImpl is not exported
// Correct: use isRef()
function processValue(val) {
const value = isRef(val) ? val.value : val;
// ...
}
// Or use unref() (more concise)
function processValue(val) {
const value = unref(val); // If ref, returns .value; otherwise returns directly
// ...
}
Trap 4: Assignment Behavior When reactive Nests ref
import { ref, reactive } from 'vue';
const count = ref(0);
const state = reactive({ count }); // Nested ref
// Assigning through the reactive object: actually modifies ref.value
state.count = 99; // Equivalent to count.value = 99!
console.log(count.value); // 99
// Assigning a new ref through the reactive object (note the difference!):
state.count = ref(100); // This replaces state.count with a NEW ref
// The original count ref is no longer associated with state.count!
console.log(state.count); // 100 (auto-unwrapped)
console.log(count.value); // 99 (the original ref didn't change)
// Verification:
state.count = 200;
console.log(count.value); // Still 99, state.count is now a different ref
Root cause: Vue's set trap in createSetter has special logic: when the old value is a ref and the new value is NOT a ref, assign the new value to the old ref's .value (preserving the ref association). When the new value IS a ref, replace the ref itself (disconnecting the old ref). This behavior is implemented in the createSetter function in Vue's source.
Chapter Summary
-
The fundamental reason
refexists is JavaScript's type system constraints: Proxy cannot proxy primitive types. Numbers and strings must be wrapped in an object (RefImpl), with getter/setter intercepting access and assignment to.valueto achieve reactivity..valueis not syntax noise — it's a necessary design under this constraint. -
Auto-unwrapping has precise boundaries: top-level template refs auto-unwrap (handled by the compiler); refs nested inside reactive objects auto-unwrap (handled by the Proxy get trap); watch sources auto-unwrap; but refs inside arrays/Maps do NOT auto-unwrap (to preserve data structure semantic integrity). Confusing these boundaries is the primary source of ref-related bugs.
-
The essence of
toRefs()/toRef()is creating a link, not a copy:ObjectRefImpl's getter internally accesses the original reactive object's property, and its setter modifies the original reactive object. Refs destructured viatoRefsmaintain two-way synchronization with the original object — they are not independent value copies. -
reactivedoes not support wholesale replacement;refdoes: this is the most important behavioral difference between the two in practical applications. Scenarios like loading data from a server (replacing null with an object) or paginated data switching (replacing the entire list) requirerefas the only correct choice. -
The core principle for selection guidelines: use
reffor things you need to destructure (destructured result remains a ref, preserving reactivity) or usereactive + toRefs; usereffor things that need wholesale replacement; primitives must useref; when both work, prioritize code readability and consistency — the community leans towardrefbecause its behavior is more predictable.