Documentation/Buki/Vue/ skills /vue-reactivity-system

📖 vue-reactivity-system

Use when Vue reactivity system with refs, reactive, computed, and watchers. Use when managing complex state in Vue applications.



Overview

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

  1. Use ref for primitives - Always wrap primitives in ref
  2. Use reactive for objects - Deep reactivity for complex state
  3. Use computed for derived state - Cached and reactive
  4. Use watch for side effects - API calls, localStorage, etc.
  5. Use watchEffect for simple effects - Auto-tracks dependencies
  6. Don't destructure reactive - Use toRefs to preserve reactivity
  7. Use readonly to prevent mutations - Protect shared state
  8. Cleanup effects properly - Return cleanup function or use onCleanup
  9. Avoid deep watching everything - Performance impact
  10. Use shallowRef/shallowReactive for large data - Better performance

Common Reactivity Pitfalls

  1. Destructuring reactive objects - Loses reactivity without toRefs
  2. Forgetting .value on refs - Common source of bugs
  3. Replacing reactive object - Breaks reactivity, use ref instead
  4. Deep watching performance - Can be slow with large objects
  5. Not cleaning up watchers - Memory leaks
  6. Accessing refs before initialization - Can be undefined
  7. Mutating props - Props are readonly
  8. Unnecessary computed - Use regular refs if not derived
  9. Synchronous effects - Usually should be async
  10. 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