Watchers
Assignment
Martina Hostettler brings back the wish from the end of part 4.
As a member, I want the app to keep my last search, so that I can pick it up again after a reload.
While testing, the volunteer noticed something else. On every keystroke the list recalculates and jumps around before the word is finished. The acceptance criteria are:
- The app stores the search text in the browser under the key
lorraine-commons-searchand puts it back into the search field on start. - The list only follows the search field once nobody has typed for 300 milliseconds.
Learning goals
After this chapter you can
- explain when a change needs a side effect,
- react to refs, functions and several sources with
watch, - use the options
immediate,deepandonce, - clean up timers and outdated answers with
onWatcherCleanup, - choose the timing of a watcher with
flush, - choose between
computed,watchandwatchEffect.
Side effects
Section titled “Side effects”A computed value should only compute. Some changes, however, should do something outside the component, such as writing an entry to the browser's storage, setting a new title for the tab or sending a request to the server. Effects like these are called side effects, and watch exists for them. It observes a source and calls a function as soon as the source changes.
<script setup>import { ref, watch } from 'vue'
const query = ref('')const title = ref('')
// The title of the browser tab lies outside the component, so a watcher sets itwatch( query, (text) => { document.title = text === '' ? 'Lorraine Commons' : `${text} · Lorraine Commons` title.value = document.title }, { immediate: true },)</script>
<template> <label for="search">Search</label> <input id="search" v-model="query" type="search"> <p>Title of the tab: {{ title }}</p></template>The watcher observes the ref query. Its function receives the new value, plus the old one as its second argument, and sets the title. Without options, it only runs after the first change. immediate: true runs it once right away, so the title is correct from the start. The playground shows its preview in a frame of its own, so the title of your tab doesn't change there, and the paragraph displays the value instead. In an app, the same code changes the title in the tab.
Sources
Section titled “Sources”The source can be a ref, a computed value, a reactive object, a function that reads values, or an array of such sources. To observe a single field, you pass a function such as () => filters.value.category. If you write just filters.value.category, watch gets the string of that moment and warns with Invalid watch source.
<script setup>import { ref, watch } from 'vue'
const filters = ref({ query: '', category: '' })const log = ref([])
// A getter watches one field, the callback gets the new and the old valuewatch( () => filters.value.category, (now, before) => log.value.push(`category: ${JSON.stringify(before)} -> ${JSON.stringify(now)}`),)
// deep also notices changes inside the objectwatch(filters, () => log.value.push('filters, deep'), { deep: true })
// once stops the watcher after its first callwatch(() => filters.value.query, () => log.value.push('first search'), { once: true })</script>
<template> <input v-model="filters.query" aria-label="Search"> <select v-model="filters.category" aria-label="Category"> <option value="">All categories</option> <option>Tools</option> <option>Garden</option> </select> <ol> <li v-for="(entry, index) in log" :key="index">{{ entry }}</li> </ol></template>The first letter in the search field prints "filters, deep" and "first search". After that, each letter only prints "filters, deep", because once stops the third watcher after its first call. When you pick a category, the first watcher reports the old and the new value. deep: true is needed because filters is a ref holding an object. Without the option, the watcher only reacts when .value receives a new object. With a reactive object as its source, watch observes all fields on its own.
Several sources go into an array, and the function receives the new values in the same order.
watch([query, category], ([text, name]) => { console.log(text, name)})Cleaning up with onWatcherCleanup
Section titled “Cleaning up with onWatcherCleanup”The list should follow the search field only after a pause. To get there, a watcher on query starts a timer on every keystroke. If the next keystroke comes before the timer has run out, the old timer must not fire anymore. onWatcherCleanup from Vue 3.5 registers a function that Vue calls before the watcher runs the next time and when it stops because the component goes away.
<script setup>import { onWatcherCleanup, ref, watch } from 'vue'
const query = ref('')const settled = ref('')
watch(query, (text) => { const timer = setTimeout(() => { settled.value = text }, 300) // Runs before the next call of the watcher, so only the last input counts onWatcherCleanup(() => clearTimeout(timer))})</script>
<template> <label for="search">Search</label> <input id="search" v-model="query" type="search"> <p>At once: {{ JSON.stringify(query) }}</p> <p>After a pause of 300 ms: {{ JSON.stringify(settled) }}</p></template>Type a word quickly. The first line follows every letter, the second one only once you stop typing for 300 milliseconds. Each new run of the watcher first cancels the timer of the previous run, so in the end only the timer of the last letter runs out. This technique is called debouncing.
According to the documentation, onWatcherCleanup has to be called in the synchronous part of the function. After an await, Vue warns that it can't find an active watcher. In an async function, the call therefore comes before the first await. The same tool helps with server answers that arrive in the wrong order, as the challenge shows.
Timing with flush
Section titled “Timing with flush”Vue doesn't call a watcher at the moment of the change. It collects the changes, just like it does for rendering. Without options, the watcher runs after the parent component has updated and before its own component updates. If it reads the DOM of its component, it still finds the old state. With flush: 'post', it only runs after Vue has updated the DOM.
watch( query, () => { // Vue has updated the DOM, the list already shows the new hits }, { flush: 'post' },)flush: 'sync' calls the watcher immediately on every change, without collecting anything. The documentation advises against using it for data that changes many times in a row, such as arrays.
Automatic dependencies with watchEffect
Section titled “Automatic dependencies with watchEffect”watchEffect does without a source. Its function runs right away and then every time a value changes that it read during its last run. That makes it an effect just like the render function from the previous chapter.
<script setup>import { ref, watchEffect } from 'vue'
const query = ref('')const category = ref('')
// watchEffect runs at once and again whenever a value it read changeswatchEffect(() => { console.log('Search', JSON.stringify(query.value), JSON.stringify(category.value))})</script>
<template> <input v-model="query" aria-label="Search"> <select v-model="category" aria-label="Category"> <option value="">All categories</option> <option>Tools</option> <option>Garden</option> </select></template>On start, the console shows a line with two empty values, and after that one line for every change to the search field or the category. watchEffect is shorter when the code reads many values. watch, on the other hand, makes visible what the code reacts to, hands you the old value and only runs when the source really changes. The examples in this course mostly use watch.
Watchers that you create directly in <script setup> are stopped by Vue together with their component. A watcher created only after an await doesn't belong to any component and stays alive. watch and watchEffect return a function that you then call to stop it yourself.
Choosing between computed and watch
Section titled “Choosing between computed and watch”The figure boils the choice down to two questions. A watcher that derives a value from other refs and writes it into a ref of its own is almost always a computed value in disguise. It can get out of step, for example when it misses the initial value because immediate is missing. The pause in the search is an exception. The ref that the list filters by depends not only on the search text but also on time, and computed can't observe time.
In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- In the title example, remove
immediate: true. What does the paragraph show on start, and when does it change? - In the example with the sources, replace the function
() => filters.value.categorywithfilters.value.category. What does the console report, and what happens when you pick a new category? - In the cleanup example, remove the line with
onWatcherCleanupand quickly type "tent". What does the second line show, one after the other? - In the
watchEffectexample, wrap the line withconsole.login the conditionif (query.value !== ''). Does the effect report a new category while the search field is empty? And afterwards?
Exercises
Section titled “Exercises”The exercises build the search from the assignment. You'll find the project in the course repo in the folder exercises/05-reactivity/watch/en. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
Store the search text on every change with localStorage.setItem under the key lorraine-commons-search. On start, the app reads the text with localStorage.getItem and puts it into the search field. Without a stored text, the field stays empty.
Solution
const STORAGE_KEY = 'lorraine-commons-search'
// The search text of the last visit, or an empty fieldconst query = ref(localStorage.getItem(STORAGE_KEY) ?? '')
// localStorage lies outside the component, so a watcher writes into itwatch(query, (text) => { localStorage.setItem(STORAGE_KEY, text)})getItem returns null when the key doesn't exist, and ?? '' turns that into an empty string. localStorage only stores strings, which suits the search text. The storage belongs to the address of the page. All projects running on localhost:5173 therefore share the same entries. You can see them in the browser's DevTools under "Application" and "Local Storage".
Exercise Going further
The list should only follow the search field once nothing has been typed for 300 milliseconds. Create a second ref settledQuery that the list filters by, and set it in a watcher with setTimeout. Every new keystroke cancels the timer of the previous one. A stored search text applies right away on start.
Solution
// The text the list filters by, it follows the field after a pause of 300 millisecondsconst settledQuery = ref(query.value)
watch(query, (text) => { const timer = setTimeout(() => { settledQuery.value = text }, 300) // The next keystroke clears the timer of this one onWatcherCleanup(() => clearTimeout(timer))})
const visibleItems = computed(() => { const text = settledQuery.value.trim().toLowerCase() return items.filter((item) => item.name.toLowerCase().includes(text))})The initial value ref(query.value) makes a stored search apply on start without a pause. onWatcherCleanup cancels the timer before the watcher runs for the next letter. Without that line, every timer fires, and the list jumps through all the intermediate states one after the other.
Exercise Challenge
In the box "In right now?", you pick a thing, and the app asks checkAvailability from src/availability.js whether it's in right now. The function stands in for the association's server and answers sometimes quickly and sometimes slowly. If you pick two things in quick succession, the app sometimes shows the answer for the first one. Make sure that only the answer for the thing picked last counts.
Solution
// The thing whose availability the server checks, 0 before the first choiceconst selectedId = ref(0)const availability = ref('')
watch(selectedId, async (id) => { // Becomes true as soon as another thing is chosen, then this answer no longer counts let outdated = false onWatcherCleanup(() => { outdated = true }) availability.value = 'Asking the server …' const answer = await checkAvailability(id) if (outdated) return const item = items.find((entry) => entry.id === answer.id) availability.value = answer.available ? `${item.name} is in right now` : `${item.name} is on loan right now`})outdated belongs to a single run of the watcher. If someone picks another thing before the answer arrives, Vue calls the cleanup function of the old run, and its outdated becomes true. When the old answer arrives later, that run ends without changing anything. onWatcherCleanup comes before the await, as the documentation requires. With a real request made with fetch, an AbortController could even cancel the old request.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
watch(filters.category, …) reports Invalid watch source. | Observe the field through a function, watch(() => filters.category, …). |
| A watcher on a ref holding an object doesn't react to changes inside it. | Pass deep: true or observe the field through a function. |
| A value is only correct after the first change, not on start. | Create the watcher with immediate: true. |
| After fast typing, every timer fires and the list jumps through all intermediate states. | Cancel the timer with onWatcherCleanup. |
Vue warns that onWatcherCleanup() can't find an active watcher. | Move the call before the first await. |
| A watcher computes a value and writes it into a ref of its own. | Use computed, unless the value depends on time, like the pause in the search. |
An AI assistant writes this.$watch or a watch option in an object. | That's the Options API. In <script setup>, you import watch from vue. |
Self-check
Section titled “Self-check”Self-check
- What is a side effect, and why doesn't it belong in
computed? - Which sources does
watchaccept? - When does Vue call the function you register with
onWatcherCleanup? - How do
watchandwatchEffectdiffer?
Answers
- An effect outside the component, such as an entry in the browser's storage. A computed value should only derive a result from its dependencies, and Vue decides on its own when and how often to compute it.
- A ref or a computed value, a reactive object, a function that reads values, or an array of such sources.
- Before the watcher runs the next time and when it stops, for example because its component goes away.
watchonly reacts to the given source, runs only after a change and hands you the old value.watchEffectruns right away and reacts to everything its function reads.
Assignment
The app remembers the search, and the list waits until someone has finished typing. Something else bothers the volunteers at the counter, and Martina Hostettler passes it on. "When you open the catalog, you have to click into the search field first. And the dialog doesn't close with Escape." For that, the app needs access to elements in the DOM and the right moment in the life of a component, the topic of the next chapter.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.