We use cookies to enhance your experience on the site
CodeWorlds

Stopping watchers - closing the telemetry channels

The night shift in the NOVA LAB Computing Center left a ticket behind. An operator closed the Ares-3 probe panel, moved over to the reactor panel, and the console log kept filling up with pressure readings from a probe nobody was looking at any more. Nothing in the code was calling it, and it kept working anyway.

That is exactly how a watcher behaves, @name. It is not a function you fire once and then forget about - it is an open listening channel, wired into a reactive source. Until somebody closes it, the channel holds memory, runs its callback on every change, and can overwrite data in a panel that was retuned to a different frequency long ago. Dr. Nova puts it in a single sentence: every channel opened in Mission Control needs a switch. In this lesson you will meet that switch, learn when Vue flips it for you and when you have to do it yourself, and learn how to clean up after work the watcher already managed to start.

The lifecycle of a watcher

Before you reach for the switch, let us fix the order of events in your head, because without it, it is hard to understand what you are actually turning off. A watcher goes through four stages, always in the same order.

Stage one is creating the watcher - you call

watch()
or
watchEffect()
. Stage two is registering dependencies and listening for changes: Vue remembers which reactive sources were read and hooks itself onto them, the way a duty officer writes a probe frequency into the console. Stage three is executing the callback on change - as long as the channel is open, every change of a source runs your function. Stage four is stopping, which means calling
stop()
or unmounting the component.

Stages two and three repeat as many times as needed, for the whole life of the panel. Stage four happens exactly once and cannot be undone: a stopped watcher cannot be switched back on, because there is no such thing as

start()
. From that moment the watcher stops reacting to changes for good. If the channel is to broadcast again, it has to be opened from scratch, as a new watcher. The one exception is a pause, which you will meet at the end of the lesson, but a pause is a different thing from a stop.

The switch you get in return

So where does that switch come from? You do not have to go looking for it - Vue hands it over at the moment the channel is created. A call to

watch()
returns a function, and a call to
watchEffect()
returns a function too. It is not an observer object and not the observed value, just a plain no-argument function that everyone calls the stop function. Until you store it in a variable, the switch falls into the void and nothing else will close the channel for you. Below is a minimal probe panel with a signal strength counter and a button that closes the channel.

1<template>
2  <p>Signal: {{ signalStrength }}</p>
3  <button @click="signalStrength++">Boost signal</button>
4  <button @click="stopChannel()">Close channel</button>
5</template>
6
7<script setup>
8import { ref, watch } from 'vue'
9
10const signalStrength = ref(0)
11
12// watch returns a stop function - keep it in a variable
13const stopChannel = watch(signalStrength, (value) => {
14  console.log('Signal:', value)
15})
16</script>

Click the first button and watch the console: every click adds a new line. Click the second button and then keep clicking the first one - the console stays silent. Now the important part, which is what did not change. The

signalStrength
counter is still reactive, the paragraph in the template still shows the current number, and the button still increases it. Stopping a watcher does not invalidate the data and does not freeze the view - only the listening goes dark. In this panel that is the only thing
stopChannel()
does, and at the same time the only way a watcher can be stopped by hand.

Three misunderstandings are worth straightening out right away, because they travel with teams moving to Vue from other tools. First, there is no

watcher.destroy()
method -
watch()
does not return an object you could call anything on, it returns a function, so there is no field there to reach for with a dot. Second, it is not true that a watcher cannot be stopped manually - you have just done it in a single line. Third, restarting the component does remove its watchers, and we will make use of that in a moment, but forcing a whole panel to reload in order to silence one channel is like cutting power to a module to switch off a single indicator lamp: it works, and along the way it destroys the entire state of the panel.

Vue closes channels for you

Before you start reflexively saving a handle for every watcher, some good news: most of the time you do not have to. A watcher created synchronously in

<script setup>
is attached to the component instance it was born in. When that component is unmounted, meaning it disappears from the page, Vue stops every watcher that belongs to it. The probe panel goes dark together with its channels, and none of them is left open in the background.

Remember this moment precisely, because it is easy to get wrong. Watchers do not stop after the first callback invocation - that behavior has to be ordered separately with the

once: true
option, available since Vue 3.4, which you saw among the
watch
options. They do not stop after a period of inactivity either - Vue counts nothing down, and there is no one-minute limit or any other expiry timer. And it is certainly not the case that they always have to be stopped by hand, because then every careless component would leave a memory leak behind it.

There is an exception, though, and it is the one behind the ticket from the night shift. Ownership by a component only works for watchers created synchronously, that is, at the moment Vue is running the

<script setup>
code. A watcher summoned later - inside
setTimeout
, in response to an event, or after an
await
in an async function - is born outside that window and has no owner. Below are two channels with identical contents, differing only in the moment they were opened.

1<script setup>
2import { ref, watchEffect } from 'vue'
3
4const pressure = ref(1013)
5
6// created synchronously in setup - owned by this component
7watchEffect(() => {
8  console.log('Pressure:', pressure.value)
9})
10
11setTimeout(() => {
12  // created one second later - nobody owns this one
13  watchEffect(() => {
14    console.log('Delayed pressure:', pressure.value)
15  })
16}, 1000)
17</script>

Both watchers are genuine watchers: both track

pressure
in the same way, both will run their callback right after creation and on every later change. The difference only shows up when the panel is closed. The first channel goes dark together with the component. The second one keeps broadcasting, and while it does, it keeps alive everything it refers to - that is a memory leak, and precisely the line in the log the duty officer could not explain.

Since Vue will not close a channel like that, we have to do it ourselves. The handle returned by

watchEffect()
has to be stored in a variable visible outside the callback, and the shutdown itself hooked onto
onUnmounted
, a member of the lifecycle hook family that Vue calls right after the component is removed from the page.

1<script setup>
2import { ref, watchEffect, onUnmounted } from 'vue'
3
4const pressure = ref(1013)
5let stopDelayed = null
6
7setTimeout(() => {
8  stopDelayed = watchEffect(() => {
9    console.log('Delayed pressure:', pressure.value)
10  })
11}, 1000)
12
13onUnmounted(() => {
14  if (stopDelayed) {
15    stopDelayed()
16  }
17})
18</script>

The

stopDelayed
variable is declared with
let
rather than
const
, because the handle only lands in it once the second has been counted out. For the same reason the guard is needed: if the operator closes the panel earlier than
setTimeout
fires, the variable still holds
null
and calling it as a function would throw an error. Apart from that, nothing in this block changed - the watcher tracks the same data and runs the same callback, and all that was added is a contingency plan for the panel disappearing.

While we are here, let us defuse a pattern you will meet in older code: creating a plain watcher inside

onMounted
and stopping it by hand in
onUnmounted
. The
onMounted
hook runs while the component is active, so in current Vue versions such a watcher gets an owner anyway and is stopped automatically. Calling
stop()
by hand breaks nothing there, because stopping an already stopped watcher does nothing at all, but it does not rescue anything either. What protects you from a leak is saving the handle for channels opened asynchronously, not the act of tidying up in
onUnmounted
as such.

A watcher that switches itself off

There is a third situation, next to the switch under a button and the tidy-up on unmount. Sometimes a channel has a fixed assignment: we are waiting for one specific state, and once it has been detected, listening stops making sense. The station receives telemetry packets from a rover, and the only thing that interests us is the moment the packet is complete. A watcher can then close itself with its own handle, by calling it from inside its own callback.

1<script setup>
2import { ref, watch } from 'vue'
3
4const telemetry = ref(null)
5
6const stopWhenComplete = watch(telemetry, (packet) => {
7  if (packet && packet.complete) {
8    console.log('Telemetry complete - closing channel')
9    stopWhenComplete()
10  }
11})
12</script>

It looks like a snake biting its own tail, but it is perfectly safe. At the moment

watch()
is called the callback is not running yet - Vue only registers dependencies. Before the first change of
telemetry
arrives, the
stopWhenComplete
constant is already initialized and points at a ready handle. The condition inside checks two things at once: whether a packet arrived at all, since the initial value is
null
, and whether it has the
complete
flag set. Once the channel is closed,
telemetry
can still be swapped as many times as you like - reactivity works, it is just that nobody is listening any more.

One trap that is better not discovered on your own in a production panel, @name. If you add the

immediate: true
option to a watcher like this one, the callback runs at once, still during the
watch()
call, that is, before the constant has been given its value - the reference to
stopWhenComplete
will then throw an error about using a variable before initialization. If a watcher is supposed to react exactly once and then disappear, I recommend the
once: true
option from Vue 3.4 instead of self-stopping: the intent is visible immediately in the options, and Vue closes the channel itself after the first invocation. Keep manual self-stopping for cases with a condition, like the
complete
flag above.

onCleanup, or tidying up interrupted work

So far we have been closing a channel that left nothing behind. In practice watchers start work that lasts in time: fetching data from a server, counting out an interval, listening on a WebSocket. Picture a panel that starts polling once per second on every change of the probe number. The operator hops through three probes, the callback runs three times, and moments later three independent intervals are ticking away in the background, even though only one panel is being looked at.

Vue gives you one mechanism for this and it is built into the callback itself. The function passed to

watch()
can take a third argument, conventionally named
onCleanup
. It is a function you call inside the callback, handing it your own cleanup function. Vue will remember it and call it at two moments: just before the next run of the callback, and at the moment the watcher is stopped. The argument order is fixed, so
onCleanup
always sits in third position, after the new and the old value.

1<script setup>
2import { ref, watch } from 'vue'
3
4const probeId = ref(1)
5
6watch(probeId, (newId, oldId, onCleanup) => {
7  const timer = setInterval(() => {
8    console.log('Polling probe', newId)
9  }, 1000)
10
11  onCleanup(() => {
12    clearInterval(timer)
13    console.log('Polling stopped for probe', newId)
14  })
15})
16</script>

Switch probes a few times and count the lines in the console: at any given moment exactly one probe is being polled, because before each new run Vue clears the previous interval. Notice what

onCleanup
does not do. It does not skip any run of the callback - the watcher reacts to every change exactly as it did before. Nor does it stop the watcher, because cleaning up and closing a channel are two different operations. Pay attention to the order as well: Vue runs the cleanup function before the callback sets off again, so the old interval goes out before the new one is created. The cleanup reaches for the
timer
from the same run in which it was registered - every callback invocation has its own set of variables and tidies up only after itself.

Since cleanup has only one correct form, let us list the ideas that will not work. Returning a cleanup function from the callback, the way

useEffect
does it in React, is dead code in Vue - the value returned by a watcher callback is ignored, and with an async callback it would be a promise anyway, not a function. There is no global
useCleanup()
function either - the
use
prefix in the Vue ecosystem marks a composable, that is, a function written by you, and not a piece of the framework API. And it is finally untrue that
watch
does not support cleanup: it supports it in exactly the same way as
watchEffect
, with one difference in the argument position. In
watchEffect
, which is given neither a new nor an old value,
onCleanup
is the first argument, and that is how the
AbortController
example in the previous lesson looked. In
watch
it is the third.

Handle and cleanup in a single panel

Let us combine both tools, because in a real mission panel they appear together. A sync channel fetches the reading of the selected probe from the server, the operator has a button to switch synchronization off, and every probe switch has to cancel a request that has not come back yet. Cancelling is the job of

AbortController
- a browser object that exposes a
signal
field passed into
fetch
and an
abort()
method that interrupts a request in flight. An aborted
fetch
rejects its promise with an error named
AbortError
, so we silence that one case, because it is not a failure but a confirmation that the cleanup did its job.

1<template>
2  <p>Probe: {{ probeId }}</p>
3  <button @click="probeId++">Next probe</button>
4  <button @click="stopSync()">Stop sync</button>
5</template>
6
7<script setup>
8import { ref, watch } from 'vue'
9
10const probeId = ref(1)
11const reading = ref(null)
12
13const stopSync = watch(probeId, async (newId, oldId, onCleanup) => {
14  const controller = new AbortController()
15  onCleanup(() => controller.abort())
16
17  try {
18    const response = await fetch(`/api/nova-lab/probes/${newId}`, {
19      signal: controller.signal
20    })
21    reading.value = await response.json()
22  } catch (error) {
23    if (error.name !== 'AbortError') {
24      throw error
25    }
26  }
27})
28</script>

This is a pattern worth having in your fingers: first

watch(source,
, then the callback with its three arguments
(newVal, oldVal, onCleanup) => {
, on the first line of the body the cleanup registration
onCleanup(() => controller.abort())
, and at the end the closing
})
. Put the cleanup registration right after the controller is created, before the first
await
- otherwise a quick probe switch will land in the window where the request is already in flight and Vue does not know yet how to interrupt it. The
oldId
argument stays in the signature even though we never use it, because a position in the argument list cannot be skipped.

Now look at the most interesting effect of this combination. Clicking the off button calls

stopSync()
, and while stopping the watcher Vue runs the last registered cleanup function - the request in flight gets cancelled, even though nobody asked for that separately. Closing the channel therefore takes with it the work the channel had managed to commission. What it does not change is the value of
reading
: data fetched earlier stays in the panel, and the only thing that disappears is the readiness to react to further probe switches.

A pause instead of a switch (Vue 3.5+)

Finally, a situation in which

stop()
is too final a tool. During a dust storm the dust sensor goes wild and floods the log with hundreds of entries. We do not want to close the channel, because in twenty minutes it will be needed again, and recreating the watcher would mean duplicating the same configuration in two places in the code. We want to mute it. Since Vue 3.5 the handle returned by
watch()
and
watchEffect()
is still a function you can call exactly as before, but it additionally carries three methods:
stop
,
pause
and
resume
. That is why you are allowed to unpack it with destructuring and reach for each of them separately.

1import { ref, watch } from 'vue'
2
3const dustLevel = ref(12)
4
5const { stop, pause, resume } = watch(dustLevel, (value) => {
6  console.log('Dust index:', value)
7})
8
9// mute the channel during the storm
10pause()
11
12// bring it back
13resume()
14
15// close it for good
16stop()

The order in this block is not accidental and it is worth remembering: first the creation with destructuring, then the pause, then the resume, and the stop always at the end, because after it none of the remaining methods has anything left to handle. After

pause()
the watcher still exists and still tracks
dustLevel
- only the execution of the callback is suspended. If the source changed during the pause, calling
resume()
runs the callback once, with the current value, so the panel catches up instead of ignoring the whole storm.

Two practical notes. In Vue versions older than 3.5 these methods simply do not exist and the only thing you get is the stop function - muting is then done with a plain flag checked at the top of the callback. And even when the methods are available,

pause()
does not replace
stop()
: a paused watcher still occupies memory and is still wired into its sources, so a channel you do not intend to come back to should be closed for good.

Remember, @name: a watcher is an open telemetry channel of Mission Control - Vue puts it out together with the panel, but a channel opened out of turn can only be closed with a handle you remembered to save yourself.

Go to CodeWorlds