Chapter 6

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. reactive solves "how to track changes to object properties." ref solves "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:


Level 1 ยท What You Need to Know (1-3 Years Experience)

6.1 Why Are Two APIs Needed?

JavaScript values fall into two categories:

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:

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:

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

  1. The fundamental reason ref exists 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 .value to achieve reactivity. .value is not syntax noise โ€” it's a necessary design under this constraint.

  2. 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.

  3. 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 via toRefs maintain two-way synchronization with the original object โ€” they are not independent value copies.

  4. reactive does not support wholesale replacement; ref does: 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) require ref as the only correct choice.

  5. The core principle for selection guidelines: use ref for things you need to destructure (destructured result remains a ref, preserving reactivity) or use reactive + toRefs; use ref for things that need wholesale replacement; primitives must use ref; when both work, prioritize code readability and consistency โ€” the community leans toward ref because its behavior is more predictable.

Rate this chapter
4.7  / 5  (59 ratings)

๐Ÿ’ฌ Comments