Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Computed, Watchers & Lifecycle

Derived state, side effects and lifecycle hooks.

The previous lessons covered putting state on the screen, reacting to user input and passing data between components. Real components also need derived state (values calculated from other values) and side effects (things that happen because state changed, like saving to an API, syncing to localStorage or starting a timer).

Vue gives you a separate tool for each job:

  • computed() for values you can derive from existing state.
  • watch() / watchEffect() for side effects when state changes.
  • Lifecycle hooks (onMounted, onUnmounted, …) for work tied to a component appearing in or leaving the DOM.

The running example throughout the rest of the track is a Task board: a list of tasks, each with a title, a done flag and a priority.

Computed properties

A computed property is a cached, reactive formula. You give Vue a getter function. Vue tracks every reactive value the getter reads and re-evaluates it only when one of those dependencies changes.

<script setup>
import { ref, computed } from 'vue';

const tasks = ref([
    { id: 1, title: 'Write migration',   done: true,  priority: 'high' },
    { id: 2, title: 'Build API resource', done: false, priority: 'high' },
    { id: 3, title: 'Style the board',    done: false, priority: 'low' },
]);
const filter = ref('all'); // 'all' | 'open' | 'done'

const visibleTasks = computed(() => {
    if (filter.value === 'open') return tasks.value.filter(t => !t.done);
    if (filter.value === 'done') return tasks.value.filter(t => t.done);
    return tasks.value;
});

const progress = computed(() => {
    const total = tasks.value.length;
    return total ? Math.round(tasks.value.filter(t => t.done).length / total * 100) : 0;
});
</script>

<template>
    <p>{{ progress }}% complete</p>
    <li v-for="task in visibleTasks" :key="task.id">{{ task.title }}</li>
</template>

Try it. Tick tasks, change the filter and watch the derived values update. The "evaluations" counter shows how often the visibleTasks getter actually ran. Typing in the unrelated "notes" box does not re-run it, because the notes value is not a dependency.

How the cache works

SOURCE STATE COMPUTED (cached) CONSUMER tasks filter notes visibleTasks progress Template render effect Changing notes invalidates nothing; changing filter re-runs only visibleTasks.

Each computed property keeps a dirty flag. When a dependency changes, Vue only marks the computed as dirty and does not recalculate yet. The next time something reads it, the getter runs and the result is cached again. So computed properties are lazy as well as cached. If nothing reads a value, its getter never runs.

Computed vs methods. A method called from the template, such as {{ visibleTasks() }}, runs on every render of the component, even when an unrelated value caused the render. A computed property only re-evaluates when its own dependencies change. Use methods for actions, computed properties for derived values.

Rules for computed getters

  • Keep them pure. No API calls, no DOM changes, no mutating other state. A getter may run at any time, or not at all.
  • Don't mutate the computed result. visibleTasks.value.push(...) changes a temporary snapshot. Change the source (tasks) instead.
  • Return a new value. In Vue 3.4+, if a computed returns a value that is === to the previous one, dependents aren't triggered. Primitives benefit most from this.

Writable computed

Occasionally you want a derived value that can also be set, typically for v-model. Provide get and set:

const allDone = computed({
    get: () => tasks.value.length > 0 && tasks.value.every(t => t.done),
    set: (value) => tasks.value.forEach(t => { t.done = value; }),
});
<label><input type="checkbox" v-model="allDone"> Mark all complete</label>

The checkbox in the demo above uses exactly this pattern.

Watchers

Computed values return something. Watchers do something. Reach for watch when a change in state should cause a side effect:

  • Persist the task list to localStorage or an API.
  • Fetch new data when a route parameter or search query changes.
  • Show a toast or play a sound when something completes.
import { ref, watch } from 'vue';

const search = ref('');

watch(search, async (newValue, oldValue) => {
    results.value = await api.searchTasks(newValue);
});

What you can watch

watch(search, cb);                          // a ref
watch(() => task.value.priority, cb);       // a getter: one property
watch([search, filter], ([s, f], [oldS, oldF]) => {}); // several sources
watch(tasks, cb, { deep: true });           // nested changes inside an array/object

Common bug: watch(task.value.priority, cb) passes a plain string to watch and nothing is tracked. To watch one property of a reactive object, pass a getter: watch(() => task.value.priority, cb).

Useful options

Option Effect
immediate: true Run the callback once straight away, as well as on later changes.
deep: true Trigger on nested mutations (tasks.value[0].done = true). It traverses the whole object, so use it sparingly.
once: true Run once on the first change, then stop (Vue 3.4+).
flush: 'post' Run after Vue has updated the DOM. Use it when the callback measures or reads the DOM.
flush: 'sync' Run synchronously on every change, before batching. Rarely needed.

watchEffect: automatic dependency tracking

watchEffect runs a function immediately and re-runs it whenever any reactive value it read last time changes, just like a render function does:

watchEffect(() => {
    localStorage.setItem('tasks', JSON.stringify(tasks.value));
    document.title = `${openCount.value} open tasks`;
});
watch watchEffect
Dependencies Explicit source(s) Whatever the function reads
Runs immediately No (unless immediate) Yes
Old value available ✅ ❌
Best for Reacting to one specific change, needing old vs new Syncing several values to something external

Cleanup and race conditions

If a watcher starts asynchronous work, a newer change can arrive before the old work finishes. Responses can then land out of order. Register a cleanup function to cancel stale work. In Vue 3.5 that's onWatcherCleanup(). The onCleanup callback argument works in every 3.x version:

watch(search, async (query, _old, onCleanup) => {
    const controller = new AbortController();
    onCleanup(() => controller.abort());   // runs before the next callback, or on unmount

    loading.value = true;
    try {
        const res = await fetch(`/api/demo/tasks?search=${encodeURIComponent(query)}`, {
            signal: controller.signal,
        });
        results.value = (await res.json()).data;
    } catch (error) {
        if (error.name !== 'AbortError') throw error;   // aborted = superseded, not a failure
    } finally {
        if (!controller.signal.aborted) loading.value = false;   // the newer request owns the spinner
    }
});

Aborting makes the pending fetch reject with an AbortError. Catch and ignore that one, and only clear loading if this request wasn't superseded. Otherwise a cancelled request could switch the spinner off while the newer one is still running.

The demo simulates a search API with a random delay. With cleanup off, type quickly and you'll often see stale results win the race. With cleanup on, only the latest request's result is ever applied.

Stopping a watcher

Watchers created synchronously in setup() are stopped automatically when the component unmounts. A watcher created later (inside a setTimeout or after an await) is not tied to the component, so stop it yourself:

const stop = watch(source, cb);
// later
stop();

Lifecycle hooks

Every component instance is created, mounted into the DOM, updated as state changes and eventually unmounted. Lifecycle hooks let you run code at those moments.

setup() state created onMounted DOM exists onUpdated after each re-render onUnmounted clean up timers, listeners state change Each phase also has an onBefore… hook: onBeforeMount, onBeforeUpdate, onBeforeUnmount.
Hook Typical use
onMounted Access template refs / the DOM, initialise a third-party library (chart, map), add window listeners.
onUpdated Rarely needed. Prefer a watcher with flush: 'post'.
onBeforeUnmount / onUnmounted Remove listeners, clear intervals, abort requests, destroy library instances.
onActivated / onDeactivated Component cached inside <KeepAlive> was shown or hidden.
onErrorCaptured Catch errors thrown by descendants (error boundaries).

There's no created hook with <script setup>, because the setup code itself is the "created" phase.

Always pair setup with cleanup

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const now = ref(new Date());
let timer;

onMounted(() => {
    timer = setInterval(() => { now.value = new Date(); }, 1000);
});

onUnmounted(() => clearInterval(timer)); // otherwise the interval leaks forever
</script>

The demo mounts and unmounts a clock component. The log shows each hook firing, and the cleanup that prevents the interval leaking after unmount.

Where to fetch initial data. In <script setup> you can start the request directly in setup, so it starts as early as possible, or in onMounted. Both are fine on the client. During SSR, onMounted doesn't run on the server, so data needed for the server render must be fetched in setup (or via the framework, e.g. Nuxt's useFetch).

Template refs: reaching the real DOM

Most of the time you never touch the DOM. Vue renders it from state. Some jobs still need the actual element, though: focusing an input, measuring a size, scrolling a list, or handing a <canvas> to a charting library. A template ref gives you that element.

<script setup>
import { ref, onMounted } from 'vue';

const titleInput = ref(null);          // same name as the ref="" attribute

onMounted(() => titleInput.value.focus()); // null until the component is mounted
</script>

<template>
    <input ref="titleInput" v-model="title" placeholder="New task…">
</template>

Vue 3.5+ adds useTemplateRef(), which decouples the variable name from the attribute and reads more clearly:

// Illustrative: Vue 3.5+ only (the demos on this page run Vue 3.4)
import { useTemplateRef, onMounted } from 'vue';

const input = useTemplateRef('title-input'); // matches <input ref="title-input">
onMounted(() => input.value?.focus());

Three rules worth remembering:

  • The ref is null during setup and becomes the element after mount. Read it in onMounted, an event handler or a flush: 'post' watcher.
  • Inside v-for, a ref collects an array of elements. The array order isn't guaranteed to match the source array.
  • On a child component, the ref is the component instance. With <script setup> you only see what the child passes to defineExpose().

Try it. The buttons use refs to focus the input, measure a box, and scroll a list to a specific task:

Don't use refs to change what Vue renders. Setting el.textContent or adding classes by hand fights the renderer, and Vue will overwrite it on the next patch. Refs are for imperative browser APIs (focus, measure, scroll, media playback, third-party widgets), not for rendering.

Choosing the right tool

You want to… Use
Show a value calculated from other state computed
Two-way bind to a derived value writable computed
Call an API when one specific value changes watch
Keep something external in sync with several values watchEffect
Touch the DOM after it's rendered a template ref read in onMounted, or watch(..., { flush: 'post' })
Release resources onUnmounted / watcher cleanup

A quick smell test: if a watcher only ever assigns another ref (watch(a, v => { b.value = v * 2 })), that should almost certainly be a computed.

Key takeaways

  • computed is lazy and cached. It re-evaluates only when its own reactive dependencies change, which makes it cheaper than calling a method from the template.
  • Keep computed getters pure. Writable computed (get / set) is handy for v-model.
  • watch is for side effects with explicit sources and old/new values. watchEffect tracks dependencies automatically.
  • Use watcher cleanup (onCleanup / onWatcherCleanup) to cancel stale async work and avoid race conditions.
  • Template refs (ref="name", or useTemplateRef() in 3.5+) give you real elements after mount, for focus, measuring and third-party libraries.
  • Lifecycle hooks give you the DOM (onMounted) and a place to clean up (onUnmounted). Every listener or timer you add needs a matching removal.