Files
2026-04-17 23:26:01 +00:00

345 lines
8.3 KiB
Markdown

---
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
<template>
<li v-for="item in items.filter(item => item.active)" :key="item.id">
{{ item.name }}
</li>
<li v-for="item in getSortedItems()" :key="item.id">
{{ item.name }}
</li>
</template>
<script setup>
import { ref } from 'vue'
const items = ref([
{ id: 1, name: 'B', active: true },
{ id: 2, name: 'A', active: false }
])
function getSortedItems() {
return [...items.value].sort((a, b) => a.name.localeCompare(b.name))
}
</script>
```
**GOOD:**
```vue
<script setup>
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'B', active: true },
{ id: 2, name: 'A', active: false }
])
const visibleItems = computed(() =>
items.value
.filter(item => item.active)
.sort((a, b) => a.name.localeCompare(b.name))
)
</script>
<template>
<li v-for="item in visibleItems" :key="item.id">
{{ item.name }}
</li>
</template>
```
### Use `computed` for reusable class/style logic
**BAD:**
```vue
<template>
<button :class="{ btn: true, 'btn-primary': type === 'primary' && !disabled, 'btn-disabled': disabled }">
{{ label }}
</button>
</template>
```
**GOOD:**
```vue
<script setup>
import { computed } from 'vue'
const props = defineProps({
type: { type: String, default: 'primary' },
disabled: Boolean,
label: String
})
const buttonClasses = computed(() => ({
btn: true,
[`btn-${props.type}`]: !props.disabled,
'btn-disabled': props.disabled
}))
</script>
<template>
<button :class="buttonClasses">
{{ label }}
</button>
</template>
```
### 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<string[]>([])
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()
})
```