Vue.js course Β· Module 4: Computed & Watch

Watch - Monitoring System

6 min read
In this lesson4

A computed answers the question "how much is it", but a good part of the station's work is not about values, it is about reactions. When an operator types a module name, the NOVA LAB database has to be queried. When the reactor temperature exceeds the norm, an alarm has to go off and the event has to be written to the log. Side effects like these must never go into a computed, because its getter should only calculate and return a result right away. Reacting to changes is the job of a watcher, the alarm sensor of Mission Control.

Basic watch()

The watch function takes two arguments and an optional third one. The first is the source, the thing we observe: a ref, an object from reactive, a getter, or an array of such sources. The second is the callback, which Vue calls after every change of the source, passing it the new and the previous value. A watcher is lazy by default: when created it does nothing and waits for the first change. Below is a module search in which the callback is an async function, and the database response delay is faked with await on a promise resolved by setTimeout.

1<template>
2  <div class="module-search" style="background: #0a0e27; color: #00ff88;">
3    <input v-model="searchQuery" placeholder="Search module..." style="background: #1a1e37; color: #00ff88;" />
4    <p v-if="isSearching" style="color: #00b4d8;">Searching...</p>
5    <ul>
6      <li v-for="result in searchResults" :key="result.id" style="border-bottom: 1px solid #00b4d8;">
7        {{ result.name }}
8      </li>
9    </ul>
10  </div>
11</template>
12
13<script setup>
14import { ref, watch } from 'vue'
15
16const searchQuery = ref('')
17const searchResults = ref([])
18const isSearching = ref(false)
19
20// Watch on a single value
21watch(searchQuery, async (newQuery, oldQuery) => {
22  console.log(`Search changed: "${oldQuery}" β†’ "${newQuery}"`)
23
24  if (newQuery.length < 2) {
25    searchResults.value = []
26    return
27  }
28
29  isSearching.value = true
30
31  // Simulating API call to the NOVA LAB database
32  await new Promise(resolve => setTimeout(resolve, 500))
33
34  searchResults.value = [
35    { id: 1, name: `Module: ${newQuery}` }
36  ]
37
38  isSearching.value = false
39})
40</script>

Type "ox", and a line with the old and new query appears in the console, followed half a second later by the search result. Queries shorter than two characters clear the list and end the callback with a return statement before anything is sent. What did not change: searchQuery is still an ordinary ref bound with v-model, and the watcher writes nothing into it, it only listens. Also notice the moment of the reaction. Vue does not call the callback on the same line where you change the value: it picks up the change and handles it in the next tick, before the DOM is refreshed. Two changes within one tick therefore produce a single call, already carrying the latest value.

Watch Options

The third argument is an options object. immediate: true runs the callback right away when the watcher is created, with the old value equal to undefined, which comes in handy for loading data when a panel starts. deep: true turns on deep observation of nested objects. flush picks the moment of the call: 'pre' (the default, before the DOM update), 'post' (after the update, when you want to read the refreshed DOM) or 'sync' (immediately on every change). once: true, available since Vue 3.4, stops the watcher after its first call.

1import { ref, watch } from 'vue'
2
3const count = ref(0)
4
5watch(count, (newValue) => {
6  console.log('Count:', newValue)
7}, {
8  immediate: true,  // Call immediately on initialization
9  deep: true,       // Deep observation of objects
10  flush: 'post',    // When to execute: 'pre', 'post', 'sync'
11  once: true        // Only once (Vue 3.4+)
12})

This block is a cheat sheet, not a pattern to copy. count is a number, so deep changes nothing here, and combining immediate with once means the callback runs once, right at creation, and the watcher switches itself off immediately. In practice the configuration always goes in the same order: import watch from vue, the source, the callback, and at the end only the options you really need.

Watch on Multiple Sources

Life support systems are assessed together: oxygen and temperature. Instead of two watchers you pass an array of sources. The callback then receives an array of new values and an array of old ones, in the same order as the sources, and the destructuring [newO2, newTemp] names them on the spot.

1import { ref, watch } from 'vue'
2
3const oxygenLevel = ref(95)
4const temperature = ref(22)
5
6// Array of sources - monitoring multiple parameters
7watch([oxygenLevel, temperature], ([newO2, newTemp], [oldO2, oldTemp]) => {
8  console.log(`Life support changed:
9    O2: ${oldO2}% β†’ ${newO2}%
10    Temp: ${oldTemp}Β°C β†’ ${newTemp}Β°C`)
11})

The callback runs when any of the sources changes. If only the oxygen changed, newTemp and oldTemp are equal. The watcher does not tell you which source moved, so compare the pairs yourself when you need to know.

Watch on Getter

An object from reactive calls for care. Writing watch(reactor.temperature, callback) will not work, because that way you pass the number 2500 rather than a reactive source, and Vue warns about an invalid watch source. You observe an object property through a getter, the function () => reactor.temperature, which Vue calls to read the current value.

1import { reactive, watch } from 'vue'
2
3const reactor = reactive({
4  name: 'Plasma Core Alpha',
5  temperature: 2500
6})
7
8// Watch on getter (function)
9watch(
10  () => reactor.temperature,
11  (newTemp) => {
12    console.log('New reactor temperature:', newTemp)
13    if (newTemp > 5000) {
14      console.warn('CRITICAL: Temperature exceeds safe limits!')
15    }
16  }
17)
18
19// Watch on deep object
20watch(
21  () => reactor,
22  (newReactor) => {
23    console.log('Reactor config changed:', newReactor)
24  },
25  { deep: true }
26)

The first watcher reacts to the temperature only - renaming the reactor will not wake it up. The second observes a getter that returns the whole object, which is why it needs { deep: true }. Without that option it would not react to a change in any field, because a getter without deep wakes the watcher only when it returns a different value than before, and here it keeps returning the same object. A reactive object passed directly, without a getter, creates a deep watcher automatically. With a ref holding an object it is the other way round: a change to a nested field such as missionData.value.systems.oxygen goes unnoticed without deep: true. After such an in-place change, the new and the old value point to the same object, so comparing them tells you nothing.

Keep the cost in mind: on every run deep walks through all nested fields, which can get expensive on large structures. Since Vue 3.5 you can limit the depth with a number, for example deep: 2. Still, I recommend that you first reach for a getter that points at the specific field - a sensor wired to a single cable is cheaper than a scanner for the whole module. In the next lesson you will meet watchEffect, which finds its sources by itself.

Remember: watch is an alarm sensor - you point it at a source, and on every change it reports both the new and the previous reading.

Code for this lesson: App.vue
1<script setup>
2import { ref, watch } from 'vue'
3
4// NOVA LAB - System Monitoring with watch()
5const oxygenLevel = ref(95)
6const powerLevel = ref(2800)
7const waterLevel = ref(85)
8const logs = ref([])
9
10// watch() - reacts to specific source changes
11watch(oxygenLevel, (newValue, oldValue) => {
12  const timestamp = new Date().toLocaleTimeString()
13  logs.value.unshift({
14    time: timestamp,
15    type: 'oxygen',
16    message: `Oxygen: ${oldValue}% β†’ ${newValue}%`,
17    color: newValue < 80 ? '#ff0044' : '#00ff88'
18  })
19
20  if (newValue < 80) {
21    logs.value.unshift({
22      time: timestamp,
23      type: 'warning',
24      message: 'WARNING: Low oxygen level detected!',
25      color: '#ffaa00'
26    })
27  }
28})
29
30watch(powerLevel, (newValue, oldValue) => {
31  const timestamp = new Date().toLocaleTimeString()
32  logs.value.unshift({
33    time: timestamp,
34    type: 'power',
35    message: `Power: ${oldValue}W β†’ ${newValue}W`,
36    color: newValue < 2000 ? '#ff0044' : '#00b4d8'
37  })
38
39  if (newValue < 2000) {
40    logs.value.unshift({
41      time: timestamp,
42      type: 'critical',
43      message: 'CRITICAL: Power below minimum threshold!',
44      color: '#ff0044'
45    })
46  }
47})
48
49// Watch multiple sources
50watch([oxygenLevel, powerLevel, waterLevel], ([o2, power, water]) => {
51  const allOptimal = o2 > 90 && power > 2500 && water > 80
52  if (allOptimal) {
53    const timestamp = new Date().toLocaleTimeString()
54    logs.value.unshift({
55      time: timestamp,
56      type: 'success',
57      message: 'All systems optimal!',
58      color: '#00ff88'
59    })
60  }
61})
62
63function adjustOxygen(delta) {
64  oxygenLevel.value = Math.max(0, Math.min(100, oxygenLevel.value + delta))
65}
66
67function adjustPower(delta) {
68  powerLevel.value = Math.max(0, powerLevel.value + delta)
69}
70
71function adjustWater(delta) {
72  waterLevel.value = Math.max(0, Math.min(100, waterLevel.value + delta))
73}
74
75function clearLogs() {
76  logs.value = []
77}
78
79function simulateEmergency() {
80  oxygenLevel.value = 65
81  powerLevel.value = 1500
82  waterLevel.value = 45
83}
84
85function restoreNominal() {
86  oxygenLevel.value = 95
87  powerLevel.value = 2800
88  waterLevel.value = 85
89}
90</script>
91
92<template>
93  <div class="monitor-system">
94    <div class="header">
95      <h1>NOVA LAB System Monitor</h1>
96      <p class="subtitle">watch() API - Reactive Monitoring</p>
97    </div>
98
99    <div class="controls-panel">
100      <div class="system-control">
101        <h3>Oxygen System</h3>
102        <div class="level-bar">
103          <div
104            class="level-fill oxygen"
105            :style="{ width: oxygenLevel + '%' }"
106          ></div>
107          <span class="level-text">{{ oxygenLevel }}%</span>
108        </div>
109        <div class="buttons">
110          <button @click="adjustOxygen(-10)">-10%</button>
111          <button @click="adjustOxygen(-5)">-5%</button>
112          <button @click="adjustOxygen(5)">+5%</button>
113          <button @click="adjustOxygen(10)">+10%</button>
114        </div>
115      </div>
116
117      <div class="system-control">
118        <h3>Power System</h3>
119        <div class="level-bar">
120          <div
121            class="level-fill power"
122            :style="{ width: (powerLevel / 30) + '%' }"
123          ></div>
124          <span class="level-text">{{ powerLevel }}W</span>
125        </div>
126        <div class="buttons">
127          <button @click="adjustPower(-200)">-200W</button>
128          <button @click="adjustPower(-100)">-100W</button>
129          <button @click="adjustPower(100)">+100W</button>
130          <button @click="adjustPower(200)">+200W</button>
131        </div>
132      </div>
133
134      <div class="system-control">
135        <h3>Water Reserves</h3>
136        <div class="level-bar">
137          <div
138            class="level-fill water"
139            :style="{ width: waterLevel + '%' }"
140          ></div>
141          <span class="level-text">{{ waterLevel }}%</span>
142        </div>
143        <div class="buttons">
144          <button @click="adjustWater(-10)">-10%</button>
145          <button @click="adjustWater(-5)">-5%</button>
146          <button @click="adjustWater(5)">+5%</button>
147          <button @click="adjustWater(10)">+10%</button>
148        </div>
149      </div>
150    </div>
151
152    <div class="scenario-buttons">
153      <button @click="simulateEmergency" class="danger">Simulate Emergency</button>
154      <button @click="restoreNominal" class="success">Restore Nominal</button>
155      <button @click="clearLogs" class="neutral">Clear Logs</button>
156    </div>
157
158    <div class="log-panel">
159      <h3>System Event Log</h3>
160      <div class="log-entries">
161        <div
162          v-for="(log, index) in logs.slice(0, 15)"
163          :key="index"
164          class="log-entry"
165          :style="{ borderLeftColor: log.color, color: log.color }"
166        >
167          <span class="log-time">[{{ log.time }}]</span>
168          <span class="log-message">{{ log.message }}</span>
169        </div>
170        <div v-if="logs.length === 0" class="no-logs">
171          No events logged yet. Adjust system parameters to see watch() in action!
172        </div>
173      </div>
174    </div>
175
176    <div class="info-box">
177      <strong>watch() API:</strong> Monitors specific reactive sources and executes callbacks when they change.
178      Perfect for logging, side effects, and conditional responses!
179    </div>
180  </div>
181</template>
182
183<style>
184* {
185  margin: 0;
186  padding: 0;
187  box-sizing: border-box;
188}
189
190body {
191  font-family: 'Courier New', monospace;
192  background: #0a0e27;
193  color: #00ff88;
194  padding: 20px;
195}
196
197.monitor-system {
198  max-width: 1100px;
199  margin: 0 auto;
200}
201
202.header {
203  text-align: center;
204  margin-bottom: 30px;
205  padding: 20px;
206  background: linear-gradient(135deg, #1a1f4c 0%, #0a0e27 100%);
207  border: 2px solid #00ff88;
208  border-radius: 10px;
209}
210
211.header h1 {
212  font-size: 2rem;
213  margin-bottom: 10px;
214  text-shadow: 0 0 20px #00ff88;
215}
216
217.subtitle {
218  color: #00b4d8;
219  font-size: 1.1rem;
220}
221
222.controls-panel {
223  display: grid;
224  gap: 20px;
225  margin-bottom: 20px;
226}
227
228.system-control {
229  background: rgba(0, 180, 216, 0.05);
230  border: 2px solid #00b4d8;
231  border-radius: 10px;
232  padding: 20px;
233}
234
235.system-control h3 {
236  color: #00b4d8;
237  margin-bottom: 15px;
238}
239
240.level-bar {
241  position: relative;
242  height: 40px;
243  background: rgba(0, 0, 0, 0.5);
244  border: 1px solid #00ff88;
245  border-radius: 5px;
246  margin-bottom: 15px;
247  overflow: hidden;
248}
249
250.level-fill {
251  height: 100%;
252  transition: width 0.3s ease;
253}
254
255.level-fill.oxygen {
256  background: linear-gradient(90deg, #00ff88, #00cc66);
257}
258
259.level-fill.power {
260  background: linear-gradient(90deg, #00b4d8, #0088ff);
261}
262
263.level-fill.water {
264  background: linear-gradient(90deg, #0088ff, #0066cc);
265}
266
267.level-text {
268  position: absolute;
269  top: 50%;
270  left: 50%;
271  transform: translate(-50%, -50%);
272  font-weight: bold;
273  font-size: 1.2rem;
274  color: white;
275  text-shadow: 0 0 5px black;
276}
277
278.buttons {
279  display: flex;
280  gap: 10px;
281  justify-content: center;
282}
283
284button {
285  background: linear-gradient(135deg, #00b4d8 0%, #0077aa 100%);
286  color: white;
287  border: none;
288  padding: 10px 20px;
289  border-radius: 5px;
290  cursor: pointer;
291  font-family: 'Courier New', monospace;
292  font-weight: bold;
293  transition: all 0.3s ease;
294}
295
296button:hover {
297  transform: translateY(-2px);
298  box-shadow: 0 5px 15px rgba(0, 180, 216, 0.3);
299}
300
301.scenario-buttons {
302  display: flex;
303  gap: 15px;
304  justify-content: center;
305  margin-bottom: 20px;
306}
307
308.scenario-buttons button {
309  padding: 15px 30px;
310  font-size: 1rem;
311}
312
313.danger {
314  background: linear-gradient(135deg, #ff0044 0%, #cc0033 100%) !important;
315}
316
317.success {
318  background: linear-gradient(135deg, #00ff88 0%, #00cc66 100%) !important;
319}
320
321.neutral {
322  background: linear-gradient(135deg, #666 0%, #444 100%) !important;
323}
324
325.log-panel {
326  background: rgba(0, 0, 0, 0.5);
327  border: 2px solid #00ff88;
328  border-radius: 10px;
329  padding: 20px;
330  margin-bottom: 20px;
331}
332
333.log-panel h3 {
334  color: #00ff88;
335  margin-bottom: 15px;
336}
337
338.log-entries {
339  max-height: 300px;
340  overflow-y: auto;
341}
342
343.log-entry {
344  padding: 10px;
345  margin-bottom: 8px;
346  background: rgba(0, 255, 136, 0.05);
347  border-left: 4px solid;
348  border-radius: 4px;
349  font-size: 0.9rem;
350  animation: slideIn 0.3s ease;
351}
352
353@keyframes slideIn {
354  from {
355    opacity: 0;
356    transform: translateX(-20px);
357  }
358  to {
359    opacity: 1;
360    transform: translateX(0);
361  }
362}
363
364.log-time {
365  opacity: 0.7;
366  margin-right: 10px;
367}
368
369.no-logs {
370  text-align: center;
371  padding: 30px;
372  color: #666;
373  font-style: italic;
374}
375
376.info-box {
377  background: rgba(0, 255, 136, 0.1);
378  border: 2px solid #00ff88;
379  border-radius: 10px;
380  padding: 15px;
381  text-align: center;
382  color: #00ff88;
383  line-height: 1.6;
384}
385</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. What is the purpose of watch() in Vue.js?

  2. 2. What parameters does the callback in watch() receive?

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

Hands-on tasks in the game

  • Click in order

    Arrange the correct watch syntax:

  • Code editor

    Complete the watch that observes searchQuery and logs changes with the new and old values.

  • Vertical ordering

    Arrange the watch configuration steps in the correct order:

  • Code editor

    Complete the watch with the immediate: true option to fetch data immediately after the watcher is created.

  • Vertical ordering

    Arrange the watch operation stages from source change to callback:

  • Horizontal ordering

    Arrange watch with the deep option in the correct order:

  • Code editor

    Complete the watch with { deep: true } to observe changes in a nested reactive object.

Useful articles