Vue.js course ยท Module 4: Computed & Watch

Stopping watchers - closing the telemetry channels

16 min read
In this lesson7

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. 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. 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: 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.

Code for this lesson: App.vue
1<script setup>
2import { ref, watch } from 'vue'
3
4// NOVA LAB - Deep Watching Complex Mission Data
5const missionData = ref({
6  crew: {
7    commander: 'Sarah Chen',
8    engineer: 'Marcus Rodriguez',
9    scientist: 'Dr. Yuki Tanaka',
10    medic: 'Dr. James Wilson'
11  },
12  systems: {
13    life_support: {
14      oxygen: 95,
15      temperature: 22,
16      humidity: 45
17    },
18    power: {
19      solar: 2800,
20      battery: 85,
21      consumption: 2200
22    },
23    communication: {
24      earth_link: true,
25      satellite: true,
26      local: true
27    }
28  },
29  inventory: {
30    food: 450,
31    water: 3200,
32    medical: 87,
33    tools: 95
34  }
35})
36
37const changeLog = ref([])
38
39// Deep watch - detects changes in nested objects
40watch(
41  missionData,
42  (newVal, oldVal) => {
43    const timestamp = new Date().toLocaleTimeString()
44    changeLog.value.unshift({
45      time: timestamp,
46      message: 'Mission data updated (deep change detected)',
47      color: '#00b4d8'
48    })
49  },
50  { deep: true } // Enable deep watching
51)
52
53// Watch specific nested property
54watch(
55  () => missionData.value.systems.life_support.oxygen,
56  (newVal, oldVal) => {
57    const timestamp = new Date().toLocaleTimeString()
58    changeLog.value.unshift({
59      time: timestamp,
60      message: `O2: ${oldVal}% โ†’ ${newVal}%`,
61      color: newVal < 80 ? '#ff0044' : '#00ff88'
62    })
63  }
64)
65
66// Watch with immediate option
67watch(
68  () => missionData.value.systems.power.battery,
69  (newVal) => {
70    if (newVal < 30) {
71      const timestamp = new Date().toLocaleTimeString()
72      changeLog.value.unshift({
73        time: timestamp,
74        message: 'Battery critically low!',
75        color: '#ff0044'
76      })
77    }
78  },
79  { immediate: true } // Run immediately on mount
80)
81
82function updateOxygen(delta) {
83  missionData.value.systems.life_support.oxygen = Math.max(0, Math.min(100,
84    missionData.value.systems.life_support.oxygen + delta
85  ))
86}
87
88function updateBattery(delta) {
89  missionData.value.systems.power.battery = Math.max(0, Math.min(100,
90    missionData.value.systems.power.battery + delta
91  ))
92}
93
94function updateInventory(item, delta) {
95  missionData.value.inventory[item] = Math.max(0,
96    missionData.value.inventory[item] + delta
97  )
98}
99
100function toggleComm(system) {
101  missionData.value.systems.communication[system] =
102    !missionData.value.systems.communication[system]
103}
104
105function emergencyProtocol() {
106  missionData.value.systems.life_support.oxygen = 65
107  missionData.value.systems.power.battery = 25
108  missionData.value.inventory.food -= 50
109  missionData.value.inventory.water -= 200
110}
111
112function restoreNominal() {
113  missionData.value.systems.life_support.oxygen = 95
114  missionData.value.systems.life_support.temperature = 22
115  missionData.value.systems.life_support.humidity = 45
116  missionData.value.systems.power.battery = 85
117  missionData.value.inventory.food = 450
118  missionData.value.inventory.water = 3200
119}
120</script>
121
122<template>
123  <div class="deep-watch">
124    <div class="header">
125      <h1>NOVA LAB Mission Control</h1>
126      <p class="subtitle">Deep Watching & Immediate Execution</p>
127    </div>
128
129    <div class="data-grid">
130      <div class="data-section crew">
131        <h3>Crew Roster</h3>
132        <div class="crew-list">
133          <div v-for="(name, role) in missionData.crew" :key="role" class="crew-member">
134            <span class="role">{{ role }}:</span>
135            <span class="name">{{ name }}</span>
136          </div>
137        </div>
138      </div>
139
140      <div class="data-section life-support">
141        <h3>Life Support</h3>
142        <div class="system-item">
143          <span>Oxygen:</span>
144          <span class="value">{{ missionData.systems.life_support.oxygen }}%</span>
145          <div class="mini-controls">
146            <button @click="updateOxygen(-5)">-</button>
147            <button @click="updateOxygen(5)">+</button>
148          </div>
149        </div>
150        <div class="system-item">
151          <span>Temperature:</span>
152          <span class="value">{{ missionData.systems.life_support.temperature }}ยฐC</span>
153        </div>
154        <div class="system-item">
155          <span>Humidity:</span>
156          <span class="value">{{ missionData.systems.life_support.humidity }}%</span>
157        </div>
158      </div>
159
160      <div class="data-section power">
161        <h3>Power Systems</h3>
162        <div class="system-item">
163          <span>Solar:</span>
164          <span class="value">{{ missionData.systems.power.solar }}W</span>
165        </div>
166        <div class="system-item">
167          <span>Battery:</span>
168          <span class="value" :style="{
169            color: missionData.systems.power.battery < 30 ? '#ff0044' : '#00ff88'
170          }">
171            {{ missionData.systems.power.battery }}%
172          </span>
173          <div class="mini-controls">
174            <button @click="updateBattery(-10)">-</button>
175            <button @click="updateBattery(10)">+</button>
176          </div>
177        </div>
178        <div class="system-item">
179          <span>Consumption:</span>
180          <span class="value">{{ missionData.systems.power.consumption }}W</span>
181        </div>
182      </div>
183
184      <div class="data-section comms">
185        <h3>Communications</h3>
186        <div class="comm-status">
187          <div
188            v-for="(status, system) in missionData.systems.communication"
189            :key="system"
190            class="comm-item"
191            @click="toggleComm(system)"
192          >
193            <span>{{ system.replace('_', ' ') }}:</span>
194            <span
195              class="status-indicator"
196              :class="{ active: status }"
197            >
198              {{ status ? 'ONLINE' : 'OFFLINE' }}
199            </span>
200          </div>
201        </div>
202      </div>
203
204      <div class="data-section inventory">
205        <h3>Inventory</h3>
206        <div class="inventory-item" v-for="(amount, item) in missionData.inventory" :key="item">
207          <span>{{ item }}:</span>
208          <span class="value">{{ amount }}</span>
209          <div class="mini-controls">
210            <button @click="updateInventory(item, -10)">-</button>
211            <button @click="updateInventory(item, 10)">+</button>
212          </div>
213        </div>
214      </div>
215    </div>
216
217    <div class="action-bar">
218      <button @click="emergencyProtocol" class="emergency">Emergency Protocol</button>
219      <button @click="restoreNominal" class="restore">Restore Nominal</button>
220    </div>
221
222    <div class="log-section">
223      <h3>Deep Watch Change Log</h3>
224      <div class="log-container">
225        <div
226          v-for="(log, index) in changeLog.slice(0, 12)"
227          :key="index"
228          class="log-entry"
229          :style="{ color: log.color }"
230        >
231          <span class="log-time">[{{ log.time }}]</span> {{ log.message }}
232        </div>
233        <div v-if="changeLog.length === 0" class="no-logs">
234          Modify any nested property to see deep watching in action...
235        </div>
236      </div>
237    </div>
238
239    <div class="info-box">
240      <strong>Deep Watching:</strong> Use <code>{ deep: true }</code> to watch nested object changes.
241      Use <code>{ immediate: true }</code> to run the watcher immediately on mount!
242    </div>
243  </div>
244</template>
245
246<style>
247* {
248  margin: 0;
249  padding: 0;
250  box-sizing: border-box;
251}
252
253body {
254  font-family: 'Courier New', monospace;
255  background: #0a0e27;
256  color: #00ff88;
257  padding: 20px;
258}
259
260.deep-watch {
261  max-width: 1200px;
262  margin: 0 auto;
263}
264
265.header {
266  text-align: center;
267  margin-bottom: 30px;
268  padding: 20px;
269  background: linear-gradient(135deg, #1a1f4c 0%, #0a0e27 100%);
270  border: 2px solid #00ff88;
271  border-radius: 10px;
272}
273
274.header h1 {
275  font-size: 2rem;
276  margin-bottom: 10px;
277  text-shadow: 0 0 20px #00ff88;
278}
279
280.subtitle {
281  color: #00b4d8;
282  font-size: 1.1rem;
283}
284
285.data-grid {
286  display: grid;
287  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
288  gap: 20px;
289  margin-bottom: 20px;
290}
291
292.data-section {
293  background: rgba(0, 180, 216, 0.05);
294  border: 2px solid #00b4d8;
295  border-radius: 10px;
296  padding: 20px;
297}
298
299.data-section h3 {
300  color: #00b4d8;
301  margin-bottom: 15px;
302  font-size: 1.1rem;
303}
304
305.crew-list {
306  display: flex;
307  flex-direction: column;
308  gap: 10px;
309}
310
311.crew-member {
312  padding: 10px;
313  background: rgba(0, 255, 136, 0.05);
314  border-radius: 5px;
315}
316
317.role {
318  color: #00b4d8;
319  text-transform: capitalize;
320  margin-right: 10px;
321}
322
323.name {
324  color: #00ff88;
325  font-weight: bold;
326}
327
328.system-item,
329.inventory-item {
330  display: flex;
331  justify-content: space-between;
332  align-items: center;
333  padding: 10px;
334  margin-bottom: 8px;
335  background: rgba(0, 255, 136, 0.05);
336  border-radius: 5px;
337}
338
339.value {
340  color: #00ff88;
341  font-weight: bold;
342}
343
344.mini-controls {
345  display: flex;
346  gap: 5px;
347}
348
349.mini-controls button {
350  background: #00b4d8;
351  color: white;
352  border: none;
353  padding: 5px 12px;
354  border-radius: 3px;
355  cursor: pointer;
356  font-family: 'Courier New', monospace;
357  font-weight: bold;
358}
359
360.mini-controls button:hover {
361  background: #00ff88;
362}
363
364.comm-status {
365  display: flex;
366  flex-direction: column;
367  gap: 10px;
368}
369
370.comm-item {
371  display: flex;
372  justify-content: space-between;
373  padding: 10px;
374  background: rgba(0, 255, 136, 0.05);
375  border-radius: 5px;
376  cursor: pointer;
377  transition: all 0.3s ease;
378}
379
380.comm-item:hover {
381  background: rgba(0, 255, 136, 0.1);
382}
383
384.status-indicator {
385  font-weight: bold;
386}
387
388.status-indicator.active {
389  color: #00ff88;
390}
391
392.action-bar {
393  display: flex;
394  gap: 20px;
395  justify-content: center;
396  margin-bottom: 20px;
397}
398
399.action-bar button {
400  padding: 15px 40px;
401  font-size: 1rem;
402  border: none;
403  border-radius: 8px;
404  cursor: pointer;
405  font-family: 'Courier New', monospace;
406  font-weight: bold;
407  transition: all 0.3s ease;
408}
409
410.emergency {
411  background: linear-gradient(135deg, #ff0044 0%, #cc0033 100%);
412  color: white;
413}
414
415.restore {
416  background: linear-gradient(135deg, #00ff88 0%, #00cc66 100%);
417  color: white;
418}
419
420.action-bar button:hover {
421  transform: translateY(-2px);
422  box-shadow: 0 5px 20px rgba(0, 255, 136, 0.3);
423}
424
425.log-section {
426  background: rgba(0, 0, 0, 0.5);
427  border: 2px solid #00ff88;
428  border-radius: 10px;
429  padding: 20px;
430  margin-bottom: 20px;
431}
432
433.log-section h3 {
434  color: #00ff88;
435  margin-bottom: 15px;
436}
437
438.log-container {
439  max-height: 250px;
440  overflow-y: auto;
441}
442
443.log-entry {
444  padding: 8px;
445  margin-bottom: 5px;
446  background: rgba(0, 255, 136, 0.05);
447  border-radius: 4px;
448  font-size: 0.9rem;
449  animation: slideIn 0.3s ease;
450}
451
452@keyframes slideIn {
453  from {
454    opacity: 0;
455    transform: translateX(-10px);
456  }
457  to {
458    opacity: 1;
459    transform: translateX(0);
460  }
461}
462
463.log-time {
464  opacity: 0.7;
465}
466
467.no-logs {
468  text-align: center;
469  padding: 30px;
470  color: #666;
471  font-style: italic;
472}
473
474.info-box {
475  background: rgba(0, 255, 136, 0.1);
476  border: 2px solid #00ff88;
477  border-radius: 10px;
478  padding: 15px;
479  text-align: center;
480  color: #00ff88;
481  line-height: 1.6;
482}
483
484code {
485  background: rgba(0, 0, 0, 0.3);
486  padding: 2px 8px;
487  border-radius: 3px;
488  color: #00b4d8;
489}
490</style>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. How do you stop a watcher in Vue.js?

  2. 2. How do you perform cleanup (resource disposal) in a watcher?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the watcher lifecycle from creation to stopping:

  • Code editor

    Complete the code - save the stop function and use onCleanup to cancel the previous request.

  • Click in order

    Arrange watch with onCleanup in the correct order:

  • Horizontal ordering

    Arrange watcher operations in order: creation, pausing, resuming, stopping:

  • Vertical ordering

    Arrange the stages of stopping a watcher and cleaning up resources:

  • Code editor

    Complete the watch that stops itself after a condition is met (e.g., data is complete).

Useful articles