Effects
In short: An effect is code for side actions (a timer, a request, the tab title); in Vue onMounted runs it once the component is on the page, watch runs it when its source changes, and the cleanup stops what was started.
Problem
The banner is gone, the checks are not
The cheese banitsa is out of stock. The buyer presses "Watch the stock" and a banner appears below the button. While the banner is open, the page checks the stock every second. Here the check only writes a line to the console.
Console
Press "Watch the stock", wait two seconds and press "Stop watching". The banner disappears, yet "Check #3", "Check #4"… keep coming in the console. Open and close the banner once more: now there are two lines a second. The timer belongs to the browser, not to the component. Nobody stopped it.
The idea
Rendering only computes
A component says what should be on the screen: from props and state it computes the markup. That is rendering. The library repeats it whenever it wants to and compares the result with the previous one.
That is why rendering has to be pure: it does nothing except compute the markup. A timer, a request to the server, a change of document.title, a subscription to events on window do something outside. They are side effects.
In <script setup> the code runs once, when the component is created. That is why the timer in the example starts once per opening. But it starts while the component is still being created, and nothing stops it when the component goes away. The template is the rendering: Vue renders it again whenever the data it reads change. It too should only describe what is shown.
A side effect needs two things that rendering does not give: a moment to start and a moment to stop.
The idea
Effect and cleanup
An effect is code that the component runs once it is on the page. The cleanup is the code that stops what was started when the component goes away.
<script setup>import { onMounted, onUnmounted } from 'vue';
const props = defineProps(['product']);
let id;onMounted(() => { id = setInterval(check, 1000);});onUnmounted(() => clearInterval(id));</script>onMounted(function)tells Vue: "run it when the component is on the page".onUnmounted(function)is called when the component leaves the page. That is the cleanup.idlives outside both functions, because both need to see it.
The cleanup mirrors the effect: setInterval and clearInterval, addEventListener and removeEventListener, a request and cancelling that request.
Try it
Stop the checks
The timer in StockWatcher starts right in the component, and nobody stops it. Move it into an effect that starts when the banner appears, and stop it when the banner goes away. The check opens the banner, waits for "Check #1", closes it and makes sure "Check #2" never comes.
Console
Solution
App.vue<script setup>
import { ref } from 'vue';
import StockWatcher from './StockWatcher.vue';
const PRODUCT = { id: 4, name: 'Cheese banitsa' };
const watching = ref(false);
</script>
<template>
<main>
<h1>{{ PRODUCT.name }}</h1>
<p>Out of stock. It will be back soon.</p>
<button type="button" @click="watching = !watching">
{{ watching ? 'Stop watching' : 'Watch the stock' }}
</button>
<StockWatcher v-if="watching" :product="PRODUCT" />
</main>
</template>
StockWatcher.vue<script setup>
import { onMounted, onUnmounted } from 'vue';
const props = defineProps(['product']);
let id;
onMounted(() => {
let checks = 0;
id = setInterval(() => {
checks++;
console.log(`Check #${checks}: ${props.product.name}`);
}, 1000);
});
onUnmounted(() => clearInterval(id));
</script>
<template>
<p class="watcher" role="status">
Watching the stock of {{ product.name }}. We will tell you as soon as it is back.
</p>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
.watcher { background: #f3efe6; border-left: 4px solid #c27c2a; padding: 8px 12px; }
label { display: block; margin: 8px 0; }
button, select { font: inherit; }
The timer goes into onMounted, and onUnmounted(() => clearInterval(id)) stops it. id is a let outside both. The check catches const id = setInterval(…) inside onMounted: that is a new id, visible only there. The outer one stays undefined, and clearInterval(undefined) stops nothing.
The idea
Dependencies
Now the buyer can pick another out-of-stock product while the banner is open. The effect checks product. As soon as the product changes, the old check has to stop and a new one has to start for the new product.
That is what watch is for. It watches a source and calls its function when the source changes:
watch(() => props.product, (product, oldProduct, onCleanup) => { const id = setInterval(check, 1000); onCleanup(() => clearInterval(id));}, { immediate: true });- The source is a function that returns the value:
() => props.product. Vue sees what it reads and knows when to call it again. { immediate: true }runs the function right away too, for the starting product. Without it,watchwaits for the first change.onCleanuprecords the cleanup of this run. Vue calls it before the next run and when the component goes away. Watchers from<script setup>stop with the component on their own.
watch replaces both functions from the last task: onMounted runs once, while watch runs for every new product.
See it
See the life of an effect
The banner opens for the banitsa, the buyer picks the cucumbers, then closes the banner.
<script setup>const props = defineProps(['product']);
watch(() => props.product, (product, old, onCleanup) => { let checks = 0; const id = setInterval(() => { checks++; console.log(`Check #${checks}: ${product.name}`); }, 1000); onCleanup(() => clearInterval(id));}, { immediate: true });</script>
<template> <p role="status">Watching the stock of {{ product.name }}</p></template>Step by step, in words
- Render: Vue builds the markup from the template. The template only describes what to show. It starts nothing.
- immediate: true runs the watcher once right away, as the component is created. The timer starts and counts the checks.
- The buyer picks another product. The getter () => props.product now returns a different value, so the watcher has to run again.
- Before the new run, the cleanup of the old one: its timer stops. Without it the old checks would go on next to the new ones.
- The effect runs again with the new product. Its own timer counts from #1.
- The buyer closes the banner: the component leaves the page. The last cleanup runs, and the timer stops with it.
- Every run has a pair: whatever the effect starts, its cleanup stops. Before the next run and when the component leaves.
The effect runs twice and the cleanup runs twice: once before the new run, once when the component is removed. In between there is always exactly one timer.
Try it
Find the bug
TypeError: Cannot read properties of undefined (reading 'name') every second. Which line is to blame?Pick the line with the bug.
watch(props.product, …) gives watch the current product, not a source. Vue cannot watch a plain object and says [Vue warn]: Invalid watch source. Because of immediate the function runs once, but with undefined instead of a product, and the timer throws every second. It needs a function: watch(() => props.product, …).Try it
Fill in
What goes in the blank so that the timer stops when the component goes away?
let id;
onMounted(() => {
id = setInterval(check, 1000);
});
(() => clearInterval(id));onUnmounted: Vue calls the function when the component leaves the page.The idea
When you do not need an effect
An effect is for a link to something outside the component: a timer, a server, the tab title, window. Two things look like an effect, but are not.
- A value computed from others. The cart total is not kept in a separate piece of state that an effect updates. It is computed during render with
computed, as in the lesson on derived state. An effect that writes it into separate state is extra work and a second place that can drift away from the cart. - A response to an action. The order is sent because the buyer pressed "Order". That goes in the click handler, not in an effect that watches some flag.
What is left is the real job of an effect: to keep the component in sync with the outside world while the component is on the page.
More precisely, if you are curious
watchEffect. watchEffect((onCleanup) => { … }) starts right away and watches everything its function reads. You do not list the sources, but you also cannot see at a glance what starts it. watch with an explicit source is clearer when you need to know exactly when it runs.
onWatcherCleanup. Since Vue 3.5 the cleanup can also be recorded with onWatcherCleanup(() => …), imported from 'vue'. It has to be called before the first await in the function.
When immediate runs. A function with { immediate: true } runs while the component is being created, before it is in the DOM. A timer and a request do not need the DOM. If the effect has to touch elements from the template, the place is onMounted.
The Options API has the same: mounted, unmounted and the watch option.
Exercise
Another product
Above the banner there is a "Product" drop-down with the banitsa and the cucumbers. Right now StockWatcher starts the timer once, when it is shown. Make it so that when the product changes, the old check stops and a new one starts from #1 for the new product. The check opens the banner, waits for "Check #1" for the banitsa, picks the cucumbers, waits for "Check #1" for them, makes sure "Check #2" for the banitsa never comes, and closes the banner.
Console
Solution
App.vue<script setup>
import { ref, computed } from 'vue';
import StockWatcher from './StockWatcher.vue';
const PRODUCTS = [
{ id: 4, name: 'Cheese banitsa' },
{ id: 13, name: 'Cucumbers' },
];
const productId = ref(4);
const watching = ref(false);
const product = computed(() => PRODUCTS.find((p) => p.id === productId.value));
</script>
<template>
<main>
<label>
Product
<select :value="productId" @change="productId = Number($event.target.value)">
<option v-for="p in PRODUCTS" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</label>
<h1>{{ product.name }}</h1>
<p>Out of stock. It will be back soon.</p>
<button type="button" @click="watching = !watching">
{{ watching ? 'Stop watching' : 'Watch the stock' }}
</button>
<StockWatcher v-if="watching" :product="product" />
</main>
</template>
StockWatcher.vue<script setup>
import { watch } from 'vue';
const props = defineProps(['product']);
watch(() => props.product, (product, oldProduct, onCleanup) => {
let checks = 0;
const id = setInterval(() => {
checks++;
console.log(`Check #${checks}: ${product.name}`);
}, 1000);
onCleanup(() => clearInterval(id));
}, { immediate: true });
</script>
<template>
<p class="watcher" role="status">
Watching the stock of {{ product.name }}. We will tell you as soon as it is back.
</p>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
.watcher { background: #f3efe6; border-left: 4px solid #c27c2a; padding: 8px 12px; }
label { display: block; margin: 8px 0; }
button, select { font: inherit; }
onMounted and onUnmounted are replaced with watch(() => props.product, (product, oldProduct, onCleanup) => { … }, { immediate: true }), and the timer is stopped with onCleanup. The check catches watch without immediate (the first check never comes), watch(props.product, …) (an invalid source) and watch without onCleanup (both timers run together).
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Props: data from above · Conditions: empty, error, loading · State · Derived state
- Continues in: Data from an API in a component · Speed: when a component renders again · Testing components · From the sandbox to a project
- Tasks on this topic: Effects, data and forms