Frontend Development 16 min read

Understanding Vue 3 ref, computed, and reactive: Definitions, Usage, and Best Practices

This article explains the purpose and internal implementation of Vue 3’s ref, computed, and reactive APIs, compares their behaviours in script and template contexts, discusses watch strategies, and offers practical guidelines for choosing and modularising these reactive primitives in large‑scale frontend projects.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Understanding Vue 3 ref, computed, and reactive: Definitions, Usage, and Best Practices

When developing a Vue 3 project, the sheer number of ref and computed definitions can become overwhelming. This article explores effective solutions and explains the original intent behind Vue’s reactive primitives.

Understanding the Intent of the Definitions

The official Vue documentation lists the order of reactive object definitions as ref , computed , reactive , and readonly , which mirrors their typical usage frequency in projects.

ref

A common question is whether a ref that receives another ref as its value should be accessed via b.value or b.value.value . Example:

const a = ref(1);
const b = ref(a);
// console.log(b.value);
// console.log(a.value);

The type definition of ref is:

function ref<T>(value: T): Ref<UnwrapRef<T>>
interface Ref<T> { value: T }

The returned type is Ref<UnwrapRef<T>> , guaranteeing an object of the form { value: x } . The unwrapping logic is:

export type UnwrapRef<T> =
  T extends ShallowRef<infer V> ? V :
  T extends Ref<infer V> ? UnwrapRefSimple<V> :
  UnwrapRefSimple<T>;

When creating a ref, Vue distinguishes three cases:

function createRef(rawValue: unknown, shallow: boolean) {
  if (isRef(rawValue)) {
    return rawValue;
  }
  return new RefImpl(rawValue, shallow);
}

If rawValue is already a Ref , it is returned unchanged, so a === b holds. For non‑ref values, a new RefImpl instance is created:

constructor(value: T, public readonly __v_isShallow: boolean) {
  this._rawValue = __v_isShallow ? value : toRaw(value);
  this._value = __v_isShallow ? value : toReactive(value);
}

When shallow is true, changes to nested properties (e.g., data.a.b = 2 ) do not trigger listeners; otherwise, the value is fully reactive.

const data = createRef({ a: { b: 1 } }, true); // shallow
watch(data.a.b, (val) => { /* not triggered */ });
data.value.a.b += 1;

computed

computed accepts a getter function and returns a read‑only reactive ref . The getter’s return value is exposed via .value . It can also be writable by providing an object with get and set methods:

// read‑only
function computed<T>(getter: (oldValue: T | undefined) => T, debuggerOptions?: DebuggerOptions): Readonly<Ref<Readonly<T>>>

// writable
function computed<T>(options: { get: (oldValue: T | undefined) => T; set: (value: T) => void }, debuggerOptions?: DebuggerOptions): Ref<T>

Example of a writable computed:

const count = ref(1);
const plusOne = computed({
  get: () => count.value + 1,
  set: (val) => { count.value = val - 1; }
});

The writable version is useful when you need a derived value that can also update its source.

reactive

reactive creates a deep reactive proxy of an object:

function reactive<T extends object>(target: T): UnwrapNestedRefs<T>

When a ref is passed, it is automatically unwrapped:

const count = ref(1);
const obj = reactive({ count }); // obj.count === count.value

If the ref contains a collection (e.g., an array), the collection itself is not unwrapped, so you must access .value on its items:

const books = reactive([ref('Vue 3 Guide')]);
console.log(books[0].value);

Common Pitfalls

ref in script vs. template

In <script setup> , a ref’s value is accessed via .value (e.g., user.value.name ). In the template, Vue automatically unwraps refs, allowing {{ user.name }} . The compiled template code shows the unwrapping:

_createElementVNode(
  "span",
  null,
  _toDisplayString(`大家好,我是${$setup.user.name}, 笔名:${$setup.user.nick}`),
  1,
  /* TEXT */
)

The $setup object is a reactive proxy whose get handler calls unref , automatically returning the unwrapped value for refs.

const shallowUnwrapHandlers: ProxyHandler
= {
  get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
  set: (target, key, value, receiver) => {
    const oldValue = target[key];
    if (isRef(oldValue) && !isRef(value)) {
      oldValue.value = value;
      return true;
    } else {
      return Reflect.set(target, key, value, receiver);
    }
  },
};

watching Ref values

When watch receives a ref, the getter returns source.value . If you pass refData.value , the source is a reactive object, and the getter uses reactiveGetter , which traverses the object (deep or shallow based on the deep option).

if (isRef(source)) {
  getter = () => source.value;
} else if (isReactive(source)) {
  getter = () => reactiveGetter(source);
}

Therefore, watch(refData, cb) only reacts to assignments to refData.value , while watch(refData.value, cb) reacts to deep property changes. To watch deep changes on a ref, use the deep: true option:

watch(refData.value, (newValue) => {
  message.value = newValue.detail.phone + '';
}, { deep: true });

Choosing Between ref and reactive

reactive works at a lower level; when you pass an object to ref , the resulting ref.value is already reactive. Thus, anything you can do with reactive can be achieved with ref , but ref offers better performance control because it does not traverse deeply unless you enable { deep: true } .

ref also supports primitive values (e.g., const visible = ref(false) ) and can be watched for whole‑object replacements, which reactive cannot detect.

When to Use computed

computed(getter) automatically tracks any ref , reactive , or other computed values used inside the getter. When those dependencies change, the computed ref updates and any watchers on it fire.

export function computed<T>(getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>, debugOptions?: DebuggerOptions, isSSR = false) {
  const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR);
  return cRef as any;
}

The internal ComputedRefImpl marks itself as a ref ( __v_isRef = true ) and creates a ReactiveEffect that re‑runs the getter whenever its dependencies change.

public readonly __v_isRef = true;
constructor(private getter: ComputedGetter<T>, ...) {
  this.effect = new ReactiveEffect(() => getter(this._value), ...);
}

Releasing watch Listeners

The watch API returns a WatchStopHandle (a function) that can be called to stop the listener. This is essential when the same logic is reused across multiple pages or components, ensuring no memory leaks.

function doWatch(...): WatchStopHandle { ... }

export function useSelectState(cb: (data) => void) {
  const stopWatch = watch(() => [store.select, store.overlap], ([select, overlap]) => {
    cb?.({ select, overlap });
  });
  return { stopWatch };
}

Summary

In large Vue components, the number of ref and computed declarations can quickly exceed twenty, making the component hard to maintain. A common pattern is to extract view‑logic into separate modules (e.g., useDialog ) that expose the reactive state, while the component file focuses solely on rendering. This mirrors the hook pattern in React and is also used by UI libraries such as Element‑Plus.

Overall, ref is ideal for simple values, computed for derived values with logic, and reactive for deep object structures when you need full reactivity.

ReactiveVue3Watchrefcomputed
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.