iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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)

Test yourself

Q1. ref() wraps a value into a…
Q2. reactive() is best for…
Q3. computed() returns…

Discussion

Loading…