.agents/skills/vue-best-practices/references/reactivity.md
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.
shallowRef() instead of ref() for primitive valuesreactive
reactive() directlyreactivecomputed
computed over watcher-assigned derived refscomputed for reusable class/style logicimmediate: true instead of duplicate initial callsshallowRef() instead of ref() for primitive values (string, number, boolean, null, etc.) for better performance.Incorrect:
import { ref } from 'vue'
const count = ref(0)
Correct:
import { shallowRef } from 'vue'
const count = shallowRef(0)
Use ref() when you often replace the entire value (state.value = newObj) and still want deep reactivity inside it, usually used for:
.value reassignment.Use reactive() when you mainly mutate properties and full replacement is uncommon, usually used for:
state.count++, state.items.push(...), state.user.name = .....value and update nested fields in place.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:
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:
import { shallowReactive } from 'vue'
const state = shallowReactive({
count: 0,
user: { name: 'Alice', age: 30 }
})
state.count++ // ✅ reactive
state.user.age = 31 // ❌ not reactive
reactivereactive() directlyBAD:
import { reactive } from 'vue'
const state = reactive({ count: 0 })
const { count } = state // ❌ disconnected from reactivity
BAD:
passing a non-getter value into watch()
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()
import { reactive, toRefs, watch } from 'vue'
const state = reactive({ count: 0 })
const { count } = toRefs(state) // ✅ count is a ref
watch(count, () => { /* ... */ }) // ✅
watch(() => state.count, () => { /* ... */ }) // ✅
computedcomputed over watcher-assigned derived refsBAD:
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:
import { computed, ref } from 'vue'
const items = ref([{ price: 10 }, { price: 20 }])
const total = computed(() =>
items.value.reduce((sum, item) => sum + item.price, 0)
)
BAD:
<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>
<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>
GOOD:
<script setup>
import { computed, ref } 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>
computed for reusable class/style logicBAD:
<template>
<button :class="{ 'btn': true, 'btn-primary': type === 'primary' && !disabled, 'btn-disabled': disabled }">
{{ label }}
</button>
</template>
GOOD:
<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>
A computed getter should only derive a value. No mutation, no API calls, no storage writes, no event emits. (Reference)
BAD:
side effects inside computed
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
const count = ref(0)
const doubled = computed(() => count.value * 2)
watch(count, (value) => {
if (value > 10)
console.warn('Too big!')
})
immediate: true instead of duplicate initial callsBAD:
import { onMounted, ref, watch } from 'vue'
const userId = ref(1)
function loadUser(id) {
// ...
}
onMounted(() => loadUser(userId.value))
watch(userId, id => loadUser(id))
GOOD:
import { ref, watch } from 'vue'
const userId = ref(1)
watch(
userId,
id => loadUser(id),
{ immediate: true }
)
When reacting to rapid changes (search boxes, filters), cancel the previous request.
GOOD:
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()
})