Reactivity Overview
Vue’s reactivity system tracks reads and writes via proxies, so when state changes the components that depend on it re-render automatically. Understanding ref, reactive, computed, and watch — and the unwrap rules — clears up 90% of Vue confusion.
ref, reactive, computed, watch, watchEffect
EXAMPLE
<script setup>
import { ref, reactive, computed, watch, watchEffect, toRefs, isRef } from 'vue';
// 1) ref — wraps a primitive or object in a reactive container
const count = ref(0);
console.log(count.value); // 0 — unwrap with .value in JS
count.value++;
// In the template, refs are auto-unwrapped:
// <p>{{ count }}</p> ← no .value needed
// 2) reactive — deep-reactive object proxy
const state = reactive({
todos: [],
filter: 'all',
});
state.todos.push({ id: 1, text: 'learn vue', done: false });
state.filter = 'active';
// reactive() does NOT need .value, but you can't destructure or it loses reactivity.
// Use toRefs() to destructure safely:
const { todos, filter } = toRefs(state); // each is a ref
// 3) ref vs reactive — when to use which
// ref — primitives, single values, or when you want the .value 'box'
// reactive — complex object state, when you don't want .value on every field
// Many Vue codebases just use ref for everything for consistency.
// 4) computed — derived state, cached until dependencies change
const remaining = computed(() => state.todos.filter((t) => !t.done).length);
const greeting = computed(() => `${remaining.value} todo(s) left`);
// Writable computed (rare)
const fullName = computed({
get: () => `${first.value} ${last.value}`,
set: (v) => { [first.value, last.value] = v.split(' '); },
});
// 5) watch — react to specific source(s)
watch(count, (n, old) => {
console.log(`count went ${old} → ${n}`);
});
// Multiple sources
watch([count, () => state.filter], ([c, f], [oldC, oldF]) => {
/* … */
});
// Deep watch on a reactive object
watch(
() => state.todos,
(newTodos) => persist(newTodos),
{ deep: true, flush: 'post' }, // 'post' runs after DOM update
);
// 6) watchEffect — run immediately + re-run when any tracked dep changes
const stop = watchEffect(() => {
// every ref/reactive value read inside is tracked
console.log(`count is ${count.value}, filter is ${state.filter}`);
});
// stop() to cancel
// 7) Side effects with cleanup
import { onCleanup } from 'vue'; // (3.5+) — or use the callback arg in watchEffect
watchEffect((onCleanup) => {
const id = setInterval(() => count.value++, 1000);
onCleanup(() => clearInterval(id));
});
// 8) shallowRef / shallowReactive — opt out of deep tracking
import { shallowRef, triggerRef } from 'vue';
const chart = shallowRef(new ECharts(el)); // 3rd-party object you don't want proxied
chart.value.update();
triggerRef(chart); // ask Vue to assume it changed
// 9) readonly — protect state from accidental mutation
import { readonly } from 'vue';
const publicState = readonly(state);
// publicState.filter = 'x' → warning + no change
// 10) When reactivity is LOST
// • Destructuring reactive():
// const { todos } = state; ← todos is now a plain array, not reactive
// Fix: const { todos } = toRefs(state); → const todos = todos.value (still tracked)
//
// • Replacing the whole reactive object:
// state = reactive({}) ← compile error in setup; but rebinding inside an obj loses refs
// Fix: mutate fields, don't reassign the container
//
// • Adding new keys to a reactive that was created without them:
// Vue 3 handles this fine (proxy-based). Vue 2 needed Vue.set().
//
// • Returning a non-ref from setup() and expecting reactivity in template:
// const c = computed(...); return { c }; ✓
// const v = c.value; return { v }; ✗
// 11) Composables — reusable reactive logic
function useCounter(initial = 0) {
const n = ref(initial);
const inc = () => n.value++;
const dec = () => n.value--;
const reset = () => (n.value = initial);
return { n, inc, dec, reset };
}
// Use in any component
// const { n, inc } = useCounter(10);
// 12) Performance
// • computed is cached — prefer over re-deriving in the template
// • avoid deep-watching huge objects; watch a specific path or use shallowRef
// • watchEffect over watch when you DON'T need old vs new values
// • {{ expensive() }} in the template re-runs every render — wrap in computed
// 13) Common bugs
// • Forgetting .value in JS for refs → 'undefined.value' or NaN
// • Destructuring a reactive() and losing reactivity
// • watch source as a value (state.filter) instead of a getter (() => state.filter)
// • Mutating a prop inside a child → warning; emit an event instead
// • Using watchEffect with async/await across yields — only deps before the first await are tracked
</script>
<template>
<p>{{ greeting }}</p>
<button @click="count++">+1</button>
<button @click="state.todos.push({ id: Date.now(), text: 'new', done: false })">add</button>
</template>
Why it matters
Refs need .value in JS but unwrap in templates; reactive objects don’t need .value but can’t be safely destructured without toRefs. When something stops re-rendering, you’ve almost always destructured a reactive or watched a value instead of a getter — those are the two failure modes worth memorising.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// ref() — wraps primitives. // reactive() — wraps objects. // Both trigger re-renders when their values change.Try it Yourself »
Exercise
Wrap a primitive into reactive state.
const count =
(0)
Three letters.
Discussion
Loading…