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.
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.valueIf 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.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.