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.

CauseWhere it belongsExample
Once, when the DOM appears or disappearsonMounted / onUnmountedfocus a field, attach an observer, start a timer
A piece of state changed, however it changedwatch or watchEffectpersist a draft, re-request when an id changes, set document.title
The user did somethingevent handlersubmit 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

Example · vue
<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.

Example · vue
<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.

Example · vue
<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.

Example · ts
// 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

  • Be the first to comment on this lesson.