Vue Reactivity System
Master Vue's reactivity system to build reactive, performant applications with optimal state management and computed properties.
Reactivity Fundamentals (Proxy-based)
Vue 3 uses JavaScript Proxies for reactivity:
import { ref, reactive, isRef, isReactive, isProxy } from 'vue';
// ref creates reactive wrapper const count = ref(0); console.log(isRef(count)); // true console.log(isProxy(count)); // false (ref itself isn't proxy) console.log(isProxy(count.value)); // false for primitives
// reactive creates proxy const state = reactive({ count: 0 }); console.log(isReactive(state)); // true console.log(isProxy(state)); // true
// Proxies track access and mutations state.count++; // Triggers reactivity count.value++; // Triggers reactivity
Ref - Reactive Primitives and Objects
Basic Ref Usage
import { ref } from 'vue';
// Primitives const count = ref(0); const name = ref('John'); const isActive = ref(true);
// Access via .value console.log(count.value); // 0 count.value++; // Update triggers reactivity
// Objects (wrapped in proxy) const user = ref({ name: 'John', age: 30 });
// Nested properties are reactive user.value.age++; // Triggers reactivity
// Can replace entire object user.value = { name: 'Jane', age: 25 }; // Works!
Shallow Ref
import { shallowRef, triggerRef } from 'vue';
// Only .value is reactive, not nested properties const state = shallowRef({ count: 0, nested: { value: 0 } });
// This triggers reactivity state.value = { count: 1, nested: { value: 1 } };
// This does NOT trigger reactivity state.value.count++; // No update!
// Manually trigger state.value.count++; triggerRef(state); // Force update
Custom Ref
import { customRef } from 'vue';
// Debounced ref function useDebouncedRef<T>(value: T, delay = 200) { let timeout: ReturnType<typeof setTimeout>;
return customRef((track, trigger) => ({ get() { track(); // Tell Vue to track this return value; }, set(newValue: T) { clearTimeout(timeout); timeout = setTimeout(() => { value = newValue; trigger(); // Tell Vue to re-render }, delay); } })); }
// Usage const searchQuery = useDebouncedRef('', 300);
// Updates are debounced searchQuery.value = 'a'; // Doesn't trigger immediately searchQuery.value = 'ab'; // Still waiting searchQuery.value = 'abc'; // Triggers after 300ms
Reactive - Deep Reactive Objects
Basic Reactive Usage
import { reactive } from 'vue';
// Create deep reactive object const state = reactive({ user: { name: 'John', profile: { email: 'john@example.com', settings: { theme: 'dark' } } }, posts: [] });
// All nested properties are reactive state.user.profile.settings.theme = 'light'; // Triggers reactivity state.posts.push({ id: 1, title: 'Post' }); // Triggers reactivity
Shallow Reactive
import { shallowReactive } from 'vue';
// Only root-level properties are reactive const state = shallowReactive({ count: 0, nested: { value: 0 } });
// This triggers reactivity state.count++; // Works
// This does NOT trigger reactivity state.nested.value++; // No update!
// But replacing works state.nested = { value: 1 }; // Triggers reactivity
Reactive Arrays
import { reactive } from 'vue';
const list = reactive<number[]>([]);
// Mutating methods trigger reactivity list.push(1); // Reactive list.pop(); // Reactive list.splice(0, 1); // Reactive list.sort(); // Reactive list.reverse(); // Reactive
// Replacement triggers reactivity const newList = reactive([1, 2, 3]);
Reactive Collections
import { reactive } from 'vue';
// Map const map = reactive(new Map<string, number>()); map.set('count', 0); // Reactive map.delete('count'); // Reactive
// Set const set = reactive(new Set<number>()); set.add(1); // Reactive set.delete(1); // Reactive
// WeakMap and WeakSet const weakMap = reactive(new WeakMap()); const weakSet = reactive(new WeakSet());
Readonly - Prevent Mutations
import { reactive, readonly, isReadonly } from 'vue';
const state = reactive({ count: 0 }); const readonlyState = readonly(state);
console.log(isReadonly(readonlyState)); // true
// Cannot mutate readonlyState.count++; // Warning in dev mode
// Original is still mutable state.count++; // Works, updates readonly view too
// Deep readonly const deepState = reactive({ nested: { value: 0 } });
const deepReadonly = readonly(deepState); deepReadonly.nested.value++; // Warning! Deep readonly
ToRef and ToRefs - Preserve Reactivity
ToRefs - Convert Reactive to Refs
import { reactive, toRefs } from 'vue';
const state = reactive({ count: 0, name: 'John' });
// Destructuring loses reactivity const { count, name } = state; // NOT reactive!
// Use toRefs to preserve reactivity const { count: countRef, name: nameRef } = toRefs(state);
// Now reactive countRef.value++; // Updates state.count console.log(state.count); // 1
ToRef - Create Ref from Property
import { reactive, toRef } from 'vue';
const state = reactive({ count: 0 });
// Create ref to specific property const countRef = toRef(state, 'count');
countRef.value++; // Updates state.count console.log(state.count); // 1
// Non-existent properties const missingRef = toRef(state, 'missing'); missingRef.value = 'now exists'; // Adds to state!
Unref and IsRef - Ref Utilities
import { ref, unref, isRef } from 'vue';
const count = ref(0); const plain = 0;
// unref: unwrap if ref, return value otherwise console.log(unref(count)); // 0 console.log(unref(plain)); // 0
// Useful for handling ref or value function double(value: number | Ref<number>): number { return unref(value) * 2; }
double(count); // 0 double(5); // 10
// isRef: check if value is ref if (isRef(count)) { console.log(count.value); } else { console.log(count); }
Computed - Derived State
Basic Computed
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
console.log(doubled.value); // 0 count.value = 5; console.log(doubled.value); // 10
// Computed is cached const expensive = computed(() => { console.log('Computing...'); return count.value * 2; });
console.log(expensive.value); // Computing... 0 console.log(expensive.value); // 0 (cached, no log) count.value = 1; console.log(expensive.value); // Computing... 2
Writable Computed
import { ref, computed } from 'vue';
const firstName = ref('John'); const lastName = ref('Doe');
const fullName = computed({
get() {
return ${firstName.value} ${lastName.value};
},
set(value) {
[firstName.value, lastName.value] = value.split(' ');
}
});
console.log(fullName.value); // John Doe fullName.value = 'Jane Smith'; console.log(firstName.value); // Jane console.log(lastName.value); // Smith
Computed Debugging
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed( () => count.value * 2, { onTrack(e) { console.log('Tracked:', e); }, onTrigger(e) { console.log('Triggered:', e); } } );
Watch - React to Changes
Watch Single Source
import { ref, watch } from 'vue';
const count = ref(0);
// Basic watch
watch(count, (newValue, oldValue) => {
console.log(Count: ${oldValue} -> ${newValue});
});
// With options watch( count, (newValue, oldValue) => { console.log('Count changed'); }, { immediate: true, // Run immediately flush: 'post', // Timing: 'pre' | 'post' | 'sync' onTrack(e) { console.log('Tracked:', e); }, onTrigger(e) { console.log('Triggered:', e); } } );
Watch Multiple Sources
import { ref, watch } from 'vue';
const x = ref(0); const y = ref(0);
// Watch array of sources
watch(
[x, y],
([newX, newY], [oldX, oldY]) => {
console.log(x: ${oldX} -> ${newX});
console.log(y: ${oldY} -> ${newY});
}
);
// Trigger when any changes x.value++; // Logs y.value++; // Logs
Watch Reactive Object
import { reactive, watch } from 'vue';
const state = reactive({ count: 0, user: { name: 'John' } });
// Watch getter watch( () => state.count, (newValue, oldValue) => { console.log('Count changed'); } );
// Deep watch entire object watch( state, (newValue, oldValue) => { console.log('State changed'); }, { deep: true } );
// Watch specific nested property watch( () => state.user.name, (newValue, oldValue) => { console.log('Name changed'); } );
Stop Watching
import { ref, watch } from 'vue';
const count = ref(0);
const stop = watch(count, (value) => {
console.log(Count: ${value});
// Stop watching when count reaches 5 if (value >= 5) { stop(); } });
// Or stop externally stop();
WatchEffect - Automatic Dependency Tracking
import { ref, watchEffect } from 'vue';
const count = ref(0); const name = ref('John');
// Automatically tracks dependencies
watchEffect(() => {
console.log(${name.value}: ${count.value});
});
// Logs: John: 0
count.value++; // Logs: John: 1 name.value = 'Jane'; // Logs: Jane: 1
// Cleanup const stop = watchEffect((onCleanup) => { const timer = setTimeout(() => { console.log(count.value); }, 1000);
// Register cleanup onCleanup(() => { clearTimeout(timer); }); });
// Stop watching stop();
WatchEffect Timing
import { ref, watchEffect, watchPostEffect, watchSyncEffect } from 'vue';
const count = ref(0);
// Default: 'pre' - before component update watchEffect(() => { console.log('Pre:', count.value); }, { flush: 'pre' });
// 'post' - after component update (access updated DOM) watchPostEffect(() => { console.log('Post:', count.value); // Can access updated DOM });
// 'sync' - synchronous (use sparingly!) watchSyncEffect(() => { console.log('Sync:', count.value); });
Effect Scope - Group Effects
import { effectScope, ref, watch } from 'vue';
const scope = effectScope();
scope.run(() => { const count = ref(0);
watch(count, () => { console.log('Count changed'); });
watchEffect(() => { console.log('Effect'); }); });
// Stop all effects in scope scope.stop();
// Nested scopes const parent = effectScope();
parent.run(() => { const child = effectScope();
child.run(() => { // Child effects });
// Stop child only child.stop(); });
// Stop parent (and all children) parent.stop();
Reactivity Utilities
Trigger and Scheduler
import { ref, triggerRef } from 'vue';
const count = ref(0);
// Manually trigger updates count.value = 1; triggerRef(count); // Force update even if value didn't change
Reactive Unwrapping
import { reactive, ref } from 'vue';
const count = ref(0); const state = reactive({ // Refs are auto-unwrapped in reactive count });
// No .value needed console.log(state.count); // 0 (not state.count.value) state.count++; // Works
// But in arrays, not unwrapped const list = reactive([ref(0)]); console.log(list[0].value); // Must use .value
When to Use This Skill
Use vue-reactivity-system when building modern, production-ready applications that require:
-
Complex state management patterns
-
Fine-grained reactivity control
-
Performance optimization through computed properties
-
Advanced watching and effect patterns
-
Understanding of Vue's reactive internals
-
Debugging reactivity issues
-
Building reactive composables
-
Large-scale applications with complex data flows
Reactivity Best Practices
-
Use ref for primitives - Always wrap primitives in ref
-
Use reactive for objects - Deep reactivity for complex state
-
Use computed for derived state - Cached and reactive
-
Use watch for side effects - API calls, localStorage, etc.
-
Use watchEffect for simple effects - Auto-tracks dependencies
-
Don't destructure reactive - Use toRefs to preserve reactivity
-
Use readonly to prevent mutations - Protect shared state
-
Cleanup effects properly - Return cleanup function or use onCleanup
-
Avoid deep watching everything - Performance impact
-
Use shallowRef /shallowReactive for large data - Better performance
Common Reactivity Pitfalls
-
Destructuring reactive objects - Loses reactivity without toRefs
-
Forgetting .value on refs - Common source of bugs
-
Replacing reactive object - Breaks reactivity, use ref instead
-
Deep watching performance - Can be slow with large objects
-
Not cleaning up watchers - Memory leaks
-
Accessing refs before initialization - Can be undefined
-
Mutating props - Props are readonly
-
Unnecessary computed - Use regular refs if not derived
-
Synchronous effects - Usually should be async
-
Not understanding proxy limitations - Some operations don't track
Advanced Patterns
Reactive State Pattern
import { reactive, readonly, computed } from 'vue';
interface State { count: number; items: string[]; }
function createStore() { const state = reactive<State>({ count: 0, items: [] });
// Computed const doubled = computed(() => state.count * 2);
// Actions function increment() { state.count++; }
function addItem(item: string) { state.items.push(item); }
// Expose readonly state return { state: readonly(state), doubled, increment, addItem }; }
const store = createStore();
Reactive Form State
import { reactive, computed, watch } from 'vue';
interface FormData { email: string; password: string; }
interface FormErrors { email?: string; password?: string; }
function useForm() { const data = reactive<FormData>({ email: '', password: '' });
const errors = reactive<FormErrors>({});
const isValid = computed(() => !errors.email && !errors.password && data.email && data.password );
// Validate on change watch( () => data.email, (email) => { if (!/^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email)) { errors.email = 'Invalid email'; } else { delete errors.email; } } );
watch( () => data.password, (password) => { if (password.length < 8) { errors.password = 'Must be 8+ characters'; } else { delete errors.password; } } );
return { data, errors, isValid }; }
Async Reactive State
import { ref, watchEffect } from 'vue';
interface User { id: number; name: string; }
function useAsyncData<T>( fetcher: () => Promise<T> ) { const data = ref<T | null>(null); const error = ref<Error | null>(null); const loading = ref(false);
async function execute() { loading.value = true; error.value = null;
try {
data.value = await fetcher();
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
}
watchEffect((onCleanup) => { let cancelled = false;
execute().then(() => {
if (cancelled) {
data.value = null;
}
});
onCleanup(() => {
cancelled = true;
});
});
return { data, error, loading, refetch: execute }; }
Reactivity Caveats and Limitations
Property Addition/Deletion
import { reactive } from 'vue';
const state = reactive<{ count?: number }>({});
// Adding new property is reactive state.count = 1; // Reactive
// But TypeScript won't know about it unless typed // Solution: Define all properties upfront or use proper types
Ref Unwrapping in Templates
<script setup lang="ts"> import { ref } from 'vue';
const count = ref(0); </script>
<template> <!-- Auto-unwrapped in templates --> <p>{{ count }}</p> <!-- Not count.value -->
<!-- But not in JavaScript expressions --> <p>{{ count + 1 }}</p> <!-- Won't work! --> <p>{{ count.value + 1 }}</p> <!-- Correct --> </template>
Non-Reactive Values
import { reactive } from 'vue';
// Primitive values in reactive are still reactive const state = reactive({ count: 0 // Reactive });
// But extracting loses reactivity let count = state.count; // Not reactive count++; // Doesn't update state
Resources
-
Vue 3 Reactivity Documentation
-
Reactivity in Depth
-
Reactivity API Reference
-
Reactivity Transform (Experimental)
-
Composition API FAQ