--- title: Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch) impact: MEDIUM impactDescription: Clear reactivity choices keep state predictable and reduce unnecessary updates in Vue 3 apps type: efficiency tags: [vue3, reactivity, ref, reactive, shallowRef, computed, watch, watchEffect, external-state, best-practice] --- # Reactivity Core Patterns (ref, reactive, shallowRef, computed, watch) **Impact: MEDIUM** - Choose the right reactive primitive first, derive with `computed`, and use watchers only for side effects. This reference covers the core reactivity decisions for local state, external data, derived values, and effects. ## Task List - Declare reactive state correctly - Always use `shallowRef()` instead of `ref()` for primitive values - Choose the correct reactive declaration method for objects/arrays/map/set - Follow best practices for `reactive` - Avoid destructuring from `reactive()` directly - Watch correctly for `reactive` - Follow best practices for `computed` - Prefer `computed` over watcher-assigned derived refs - Keep filtered/sorted derivations out of templates - Use `computed` for reusable class/style logic - Keep computed getters pure (no side effects) and put side effects in watchers - Follow best practices for watchers - Use `immediate: true` instead of duplicate initial calls - Clean up async effects for watchers ## Declare reactive state correctly ### Always use `shallowRef()` instead of `ref()` for primitive values (string, number, boolean, null, etc.) for better performance. **Incorrect:** ```ts import { ref } from 'vue' const count = ref(0) ``` **Correct:** ```ts import { shallowRef } from 'vue' const count = shallowRef(0) ``` ### Choose the correct reactive declaration method for objects/arrays/map/set Use `ref()` when you often **replace the entire value** (`state.value = newObj`) and still want deep reactivity inside it, usually used for: - Frequently reassigned state (replace fetched object/list, reset to defaults, switch presets). - Composable return values where updates happen mostly via `.value` reassignment. Use `reactive()` when you mainly **mutate properties** and full replacement is uncommon, usually used for: - “Single state object” patterns (stores/forms): `state.count++`, `state.items.push(...)`, `state.user.name = ...`. - Situations where you want to avoid `.value` and update nested fields in place. ```ts import { reactive } from 'vue' const state = reactive({ count: 0, user: { name: 'Alice', age: 30 } }) state.count++ // ✅ reactive state.user.age = 31 // ✅ reactive // ❌ avoid replacing the reactive object reference: // state = reactive({ count: 1 }) ``` Use `shallowRef()` when the value is **opaque / should not be proxied** (class instances, external library objects, very large nested data) and you only want updates to trigger when you **replace** `state.value` (no deep tracking), usually used for: - Storing external instances/handles (SDK clients, class instances) without Vue proxying internals. - Large data where you update by replacing the root reference (immutable-style updates). ```ts import { shallowRef } from 'vue' const user = shallowRef({ name: 'Alice', age: 30 }) user.value.age = 31 // ❌ not reactive user.value = { name: 'Bob', age: 25 } // ✅ triggers update ``` Use `shallowReactive()` when you want **only top-level properties** reactive; nested objects remain raw, usually used for: - Container objects where only top-level keys change and nested payloads should stay unmanaged/unproxied. - Mixed structures where Vue tracks the wrapper object, but not deeply nested or foreign objects. ```ts import { shallowReactive } from 'vue' const state = shallowReactive({ count: 0, user: { name: 'Alice', age: 30 } }) state.count++ // ✅ reactive state.user.age = 31 // ❌ not reactive ``` ## Best practices for `reactive` ### Avoid destructuring from `reactive()` directly **BAD:** ```ts import { reactive } from 'vue' const state = reactive({ count: 0 }) const { count } = state // ❌ disconnected from reactivity ``` ### Watch correctly for reactive **BAD:** passing a non-getter value into `watch()` ```ts import { reactive, watch } from 'vue' const state = reactive({ count: 0 }) // ❌ watch expects a getter, ref, reactive object, or array of these watch(state.count, () => { /* ... */ }) ``` **GOOD:** preserve reactivity with `toRefs()` and use a getter for `watch()` ```ts import { reactive, toRefs, watch } from 'vue' const state = reactive({ count: 0 }) const { count } = toRefs(state) // ✅ count is a ref watch(count, () => { /* ... */ }) // ✅ watch(() => state.count, () => { /* ... */ }) // ✅ ``` ## Best practices for `computed` ### Prefer `computed` over watcher-assigned derived refs **BAD:** ```ts import { ref, watchEffect } from 'vue' const items = ref([{ price: 10 }, { price: 20 }]) const total = ref(0) watchEffect(() => { total.value = items.value.reduce((sum, item) => sum + item.price, 0) }) ``` **GOOD:** ```ts import { ref, computed } from 'vue' const items = ref([{ price: 10 }, { price: 20 }]) const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0) ) ``` ### Keep filtered/sorted derivations out of templates **BAD:** ```vue ``` **GOOD:** ```vue ``` ### Use `computed` for reusable class/style logic **BAD:** ```vue ``` **GOOD:** ```vue ``` ### Keep computed getters pure (no side effects) and put side effects in watchers instead A computed getter should only derive a value. No mutation, no API calls, no storage writes, no event emits. ([Reference](https://vuejs.org/guide/essentials/computed.html#best-practices)) **BAD:** side effects inside computed ```ts const count = ref(0) const doubled = computed(() => { // ❌ side effect if (count.value > 10) console.warn('Too big!') return count.value * 2 }) ``` **GOOD:** pure computed + `watch()` for side effects ```ts const count = ref(0) const doubled = computed(() => count.value * 2) watch(count, (value) => { if (value > 10) console.warn('Too big!') }) ``` ## Best practices for watchers ### Use `immediate: true` instead of duplicate initial calls **BAD:** ```ts import { ref, watch, onMounted } from 'vue' const userId = ref(1) function loadUser(id) { // ... } onMounted(() => loadUser(userId.value)) watch(userId, (id) => loadUser(id)) ``` **GOOD:** ```ts import { ref, watch } from 'vue' const userId = ref(1) watch( userId, (id) => loadUser(id), { immediate: true } ) ``` ### Clean up async effects for watchers When reacting to rapid changes (search boxes, filters), cancel the previous request. **GOOD:** ```ts const query = ref('') const results = ref([]) watch(query, async (q, _prev, onCleanup) => { const controller = new AbortController() onCleanup(() => controller.abort()) const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, { signal: controller.signal, }) results.value = await res.json() }) ```