Side Effects in the Right Place
How to choose between a lifecycle hook, a watcher and an event handler for a side effect, and why onUpdated is rarely the answer.
Most "why did that run twice" bugs are a side effect in the wrong place. Vue offers three places, and the choice follows from what causes the effect.
| Cause | Where it belongs | Example |
|---|---|---|
| Once, when the DOM appears or disappears | onMounted / onUnmounted | focus a field, attach an observer, start a timer |
| A piece of state changed, however it changed | watch or watchEffect | persist a draft, re-request when an id changes, set document.title |
| The user did something | event handler | submit a form, open a dialog, emit to the parent |
The two common mistakes
The first is treating onUpdated as a general "something changed" hook. It runs after every DOM update of the component, cannot tell you which state changed, and mutating state inside it schedules another update that calls it again — a loop that locks the tab. A watcher on the specific source is what you wanted.
The second is an effect that sits in the handler when it belongs to the state. If a click both sets a value and saves it, every other path that sets that value — a reset button, a restored draft, a parent passing something new — skips the save. Keep the handler about intent: it sets state. Let a watcher own the consequence.
A value computed from other state is none of the three. That is computed, not a watcher writing into a ref.
Example
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const props = defineProps<{ draftKey: string }>()
const emit = defineEmits<{ submit: [text: string] }>()
const text = ref('')
// Once, on the client only: restore whatever was left behind.
onMounted(() => {
text.value = localStorage.getItem(props.draftKey) ?? ''
})
// Whenever the text changes, whoever changed it.
watch(text, (value) => {
localStorage.setItem(props.draftKey, value)
})
// Only when the user asks for it.
function publish() {
emit('submit', text.value)
text.value = ''
}
</script>
<template>
<form class="composer" @submit.prevent="publish">
<textarea v-model="text" rows="4"></textarea>
<button type="submit" :disabled="text.trim() === ''">Publish</button>
</form>
</template>
<style scoped>
.composer {
display: grid;
gap: 0.5rem;
}
</style>When to use it
- Decide where a save-the-draft call belongs when three different controls can change the draft.
- Replace an onUpdated hook that fires far more often than expected.
- Work out why a side effect is skipped when state is changed programmatically rather than by a click.
- Remove a watcher that only copies one ref into another.
More examples
The same job done wrong, in onUpdated
Two separate failures. Incrementing saves inside onUpdated changes state, which schedules an update, which calls onUpdated again: an infinite loop. And the save runs after every DOM update of this component, including updates that had nothing to do with text, so unrelated state changes write the draft again. Replacing the hook with watch(text, ...) fixes both, because the watcher names the source it cares about and does not re-trigger itself.
<script setup lang="ts">
import { onUpdated, ref } from 'vue'
const text = ref('')
const saves = ref(0)
// Both lines are mistakes. Do not copy this.
onUpdated(() => {
localStorage.setItem('draft', text.value)
saves.value += 1
})
</script>
<template>
<div class="composer">
<textarea v-model="text" rows="4"></textarea>
<p>Saved {{ saves }} times</p>
</div>
</template>
<style scoped>
.composer {
display: grid;
gap: 0.5rem;
}
</style>Intent in the handler, consequence in the watcher
If the localStorage.setItem call lived inside choose, reset would silently stop persisting the preference, and so would any future code path that assigns to panel. Putting it in a watcher means the effect is attached to the state rather than to one button. The test for which you want: ask whether the effect should still happen if the state changes some other way. If yes, it is a watcher.
<script setup lang="ts">
import { ref, watch } from 'vue'
type Panel = 'list' | 'map'
const panel = ref<Panel>('list')
// Owns the effect, so every path that changes panel is covered.
watch(panel, (value) => {
localStorage.setItem('preferred-panel', value)
})
function choose(value: Panel) {
panel.value = value
}
function reset() {
panel.value = 'list'
}
</script>
<template>
<div class="switcher">
<button type="button" :aria-pressed="panel === 'list'" @click="choose('list')">
List
</button>
<button type="button" :aria-pressed="panel === 'map'" @click="choose('map')">
Map
</button>
<button type="button" @click="reset">Reset</button>
</div>
</template>
<style scoped>
.switcher {
display: flex;
gap: 0.5rem;
}
</style>Derived values are neither
A watcher that does nothing but assign one ref from another is almost always a computed written the long way round. The computed recalculates on read, caches until a dependency changes, and cannot fall out of sync; the watcher version can, because it only runs after the source changes and leaves a stale value visible in between. Keep watchers for effects that leave the reactive system: storage, the DOM, the network, logging.
// src/lib/cart.ts
import { computed, ref } from 'vue'
export const prices = ref<number[]>([12, 5, 3])
export const total = computed(() => prices.value.reduce((sum, n) => sum + n, 0))
console.log(total.value)
// Output: 20
prices.value.push(10)
console.log(total.value)
// Output: 30
Discussion