Vue.js course · Module 4: Computed & Watch
Computed and Watch in Practice - Mission Systems
In this lesson4
Mission Control has ordered three panels: a module search that does not bombard the server with a request after every letter, a trajectory calculator with a history of results, and a registration form with validation. Each one combines computed with a watcher, and the whole art lies in not mixing up the roles. The rule is short: when you need a value, write a computed, and when you need an action - a request, a log entry, a timer - write a watch. The computed getter must stay pure: it only calculates and returns a result, it does not send requests, does not write to localStorage and does not change other data.
Module Search System with Debounce
An operator typing the word "reactor" would send seven requests, although only the last one matters. The debounce technique holds the action back until the data stops changing for a set time, here 300 milliseconds. Let us start with the template: a search field, a loading message and a list of results.
1<template>
2 <div class="module-search-system" style="background: #0a0e27; color: #00ff88;">
3 <input v-model="searchInput" placeholder="Search modules..." style="background: #1a1e37; color: #00ff88;" />
4 <p v-if="isLoading" style="color: #00b4d8;">Scanning NOVA LAB database...</p>
5 <ul>
6 <li v-for="item in results" :key="item.id" style="border-bottom: 1px solid #00b4d8;">
7 {{ item.name }}
8 </li>
9 </ul>
10 </div>
11</template>The field is bound to searchInput, the raw text that changes after every keystroke. The list reads results, and the message shows up while isLoading is true. Now the script with the first watcher, which delays copying the text into searchQuery.
1<script setup>
2import { ref, watch } from 'vue'
3
4const searchInput = ref('')
5const searchQuery = ref('')
6const results = ref([])
7const isLoading = ref(false)
8
9// Debounce - delay the search
10let debounceTimeout
11watch(searchInput, (value) => {
12 clearTimeout(debounceTimeout)
13 debounceTimeout = setTimeout(() => {
14 searchQuery.value = value
15 }, 300)
16})The order of steps is worth memorizing: the debounceTimeout variable outside the watcher, then watch(searchInput, ...), inside it clearTimeout cancelling the previous countdown and setTimeout starting a new one. Every keystroke resets the clock, so only text followed by a pause reaches searchQuery. The field on screen still shows the letters immediately - only the search waits. The second watcher performs the actual request.
1// Execute search
2watch(searchQuery, async (query) => {
3 if (!query) {
4 results.value = []
5 return
6 }
7
8 isLoading.value = true
9
10 try {
11 const response = await fetch(`/api/nova-lab/modules?q=${query}`)
12 results.value = await response.json()
13 } finally {
14 isLoading.value = false
15 }
16})
17</script>The callback is async, and the try/finally block guarantees that isLoading returns to false even after a network error. Asynchrony has a price, though: if an older request comes back later than a newer one, it overwrites the fresh results. So in a production panel, add cleanup here with onCleanup and AbortController, just as in the lesson on stopping watchers.
Trajectory Calculator with History
The second panel computes an operation on velocity and time and keeps a history of results. The result is a value, so it goes into a computed, and a history entry is an action, so it goes into a watcher. The template contains two number fields, a list with the operation and the history.
1<template>
2 <div class="trajectory-calc" style="background: #0a0e27; color: #00ff88;">
3 <label>Velocity (km/s):</label>
4 <input v-model.number="velocity" type="number" style="background: #1a1e37; color: #00ff88;" />
5
6 <label>Operation:</label>
7 <select v-model="operation" style="background: #1a1e37; color: #00ff88;">
8 <option value="+">Add</option>
9 <option value="-">Subtract</option>
10 <option value="*">Multiply</option>
11 <option value="/">Divide</option>
12 </select>
13
14 <label>Time (s):</label>
15 <input v-model.number="time" type="number" style="background: #1a1e37; color: #00ff88;" />
16 <p style="color: #00b4d8;">= {{ result }}</p>
17
18 <h3>Calculation history:</h3>
19 <ul>
20 <li v-for="(entry, i) in history" :key="i" style="color: #00b4d8;">{{ entry }}</li>
21 </ul>
22 </div>
23</template>The fields use v-model.number, so numbers rather than text reach the variables, and the history list renders its entries below the result. The first part of the script holds the panel state and the computed with the result.
1<script setup>
2import { ref, computed, watch } from 'vue'
3
4const velocity = ref(25)
5const time = ref(3600)
6const operation = ref('*')
7const history = ref([])
8
9const result = computed(() => {
10 switch (operation.value) {
11 case '+': return velocity.value + time.value
12 case '-': return velocity.value - time.value
13 case '*': return velocity.value * time.value
14 case '/': return time.value !== 0 ? velocity.value / time.value : 'Error'
15 default: return 0
16 }
17})The switch statement picks the operation, and when dividing by zero the computed returns the text Error instead of infinity. The second part of the script adds results to the history.
1// Save to calculation history
2watch(result, (newResult) => {
3 const entry = `${velocity.value} ${operation.value} ${time.value} = ${newResult}`
4 history.value.unshift(entry)
5
6 // Limit to 10 entries
7 if (history.value.length > 10) {
8 history.value.pop()
9 }
10})
11</script>The watcher observes the result computed, because a computed is a perfectly valid source for watch. The unshift method adds an entry at the beginning of the array, and pop removes the oldest one once there are more than 10 entries. Note something less than obvious: when the velocity is 0, changing the time while multiplying adds no entry, because the result is still 0 and the watcher reacts only to a change of value.
Form Validation
The third panel is a registration form with an email address and a password. Computed alone is enough here, because every message and every button state is a value derived from what the user typed. The template binds the fields, shows errors and blocks the button.
1<template>
2 <form @submit.prevent="submit">
3 <div>
4 <input v-model="email" placeholder="Email" />
5 <span v-if="emailError" class="error">{{ emailError }}</span>
6 </div>
7
8 <div>
9 <input v-model="password" type="password" placeholder="Password" />
10 <div class="strength" :class="passwordStrengthClass">
11 Password strength: {{ passwordStrength }}
12 </div>
13 </div>
14
15 <button :disabled="!isFormValid">Register</button>
16 </form>
17</template>The button gets :disabled="!isFormValid", so it unlocks by itself once the form becomes valid, and :class switches the look of the password strength indicator. The first part of the script defines the fields and two computed messages.
1<script setup>
2import { ref, computed } from 'vue'
3
4const email = ref('')
5const password = ref('')
6
7const emailError = computed(() => {
8 if (!email.value) return ''
9 const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
10 return regex.test(email.value) ? '' : 'Invalid email'
11})
12
13const passwordStrength = computed(() => {
14 const pwd = password.value
15 if (pwd.length < 6) return 'Weak'
16 if (pwd.length < 10) return 'Medium'
17 if (/[A-Z]/.test(pwd) && /[0-9]/.test(pwd)) return 'Strong'
18 return 'Medium'
19})emailError first checks whether the field is empty, because an empty form should not shout an error before the user starts typing. Then a regular expression tests whether the address looks like "something@something.something" with no spaces. passwordStrength rates the password by its length and by the presence of an uppercase letter and a digit. The second part of the script builds on these results.
1const passwordStrengthClass = computed(() => ({
2 weak: passwordStrength.value === 'Weak',
3 medium: passwordStrength.value === 'Medium',
4 strong: passwordStrength.value === 'Strong'
5}))
6
7const isFormValid = computed(() => {
8 return email.value &&
9 !emailError.value &&
10 password.value.length >= 6
11})
12
13function submit() {
14 console.log('Form submitted')
15}
16</script>passwordStrengthClass reads passwordStrength, and isFormValid reads emailError - that is a computed chain, in which each link recalculates only after the previous link changes. The same mechanism in its simplest form looks like this:
1const price = ref(100)
2const tax = computed(() => price.value * 0.23)
3const total = computed(() => price.value + tax.value)Changing price invalidates both computed values, and total recalculates with the new tax on its next read. The chain needs no watcher and no manual synchronization.
Debugging computed and watchers
When a computed does not recalculate when you expect it to, start with Vue DevTools. It is a browser extension, also available as a Vite plugin and as a standalone app, that shows the component tree and the state of each component, including the current values of refs and computed properties. For precise tracking there are the onTrack and onTrigger options, passed as the second argument of computed or among the options of watch.
1const tax = computed(() => price.value * 0.23, {
2 onTrack(event) {
3 console.log('tracked', event.key)
4 },
5 onTrigger(event) {
6 console.log('triggered', event.key, event.newValue)
7 }
8})onTrack fires when the getter registers a dependency, and onTrigger when a change of that dependency invalidates the result, provided the computed is actually in use, for example in a template. Both options work only in development mode, so in the production build they cost nothing. I recommend DevTools first, and onTrack with onTrigger only when the state inspector is not enough. In the module project you will combine all of these tools in a single mission dashboard.
Remember: a computed answers the question "how much is it", and a watch answers "what to do when it changes" - mix up those roles, and the mission panel either calculates too much or does not react at all.
Code for this lesson: App.vue
1<script setup>
2import { ref, watch, watchEffect, onUnmounted } from 'vue'
3
4// NOVA LAB - Advanced Watching: Multiple Sources & Cleanup
5const sensorA = ref(75)
6const sensorB = ref(82)
7const sensorC = ref(91)
8const isMonitoring = ref(false)
9const alerts = ref([])
10const sensorReadings = ref([])
11
12let monitoringInterval = null
13
14// Watch multiple sources
15watch([sensorA, sensorB, sensorC], ([a, b, c], [oldA, oldB, oldC]) => {
16 const timestamp = new Date().toLocaleTimeString()
17
18 if (a !== oldA) {
19 alerts.value.unshift({
20 time: timestamp,
21 sensor: 'A',
22 value: a,
23 previous: oldA,
24 color: '#00ff88'
25 })
26 }
27 if (b !== oldB) {
28 alerts.value.unshift({
29 time: timestamp,
30 sensor: 'B',
31 value: b,
32 previous: oldB,
33 color: '#00b4d8'
34 })
35 }
36 if (c !== oldC) {
37 alerts.value.unshift({
38 time: timestamp,
39 sensor: 'C',
40 value: c,
41 previous: oldC,
42 color: '#ffaa00'
43 })
44 }
45
46 // Calculate average
47 const avg = Math.round((a + b + c) / 3)
48 if (avg < 70) {
49 alerts.value.unshift({
50 time: timestamp,
51 sensor: 'SYSTEM',
52 value: avg,
53 message: 'Average below threshold!',
54 color: '#ff0044'
55 })
56 }
57})
58
59// Watch with cleanup function
60watch(isMonitoring, (newValue, oldValue, onCleanup) => {
61 const timestamp = new Date().toLocaleTimeString()
62
63 if (newValue) {
64 alerts.value.unshift({
65 time: timestamp,
66 message: '▶Continuous monitoring started',
67 color: '#00ff88'
68 })
69
70 // Start interval
71 monitoringInterval = setInterval(() => {
72 const reading = {
73 time: new Date().toLocaleTimeString(),
74 a: sensorA.value,
75 b: sensorB.value,
76 c: sensorC.value,
77 avg: Math.round((sensorA.value + sensorB.value + sensorC.value) / 3)
78 }
79 sensorReadings.value.unshift(reading)
80
81 // Simulate sensor fluctuation
82 sensorA.value = Math.max(0, Math.min(100, sensorA.value + (Math.random() - 0.5) * 2))
83 sensorB.value = Math.max(0, Math.min(100, sensorB.value + (Math.random() - 0.5) * 2))
84 sensorC.value = Math.max(0, Math.min(100, sensorC.value + (Math.random() - 0.5) * 2))
85 }, 2000)
86
87 // Cleanup function - called when watcher stops or before re-run
88 onCleanup(() => {
89 if (monitoringInterval) {
90 clearInterval(monitoringInterval)
91 const stopTime = new Date().toLocaleTimeString()
92 alerts.value.unshift({
93 time: stopTime,
94 message: '⏸Monitoring stopped (cleanup executed)',
95 color: '#ffaa00'
96 })
97 }
98 })
99 }
100})
101
102// watchEffect for computed average
103const avgReading = ref(0)
104watchEffect(() => {
105 avgReading.value = Math.round((sensorA.value + sensorB.value + sensorC.value) / 3)
106})
107
108function adjustSensor(sensor, delta) {
109 if (sensor === 'A') {
110 sensorA.value = Math.max(0, Math.min(100, sensorA.value + delta))
111 } else if (sensor === 'B') {
112 sensorB.value = Math.max(0, Math.min(100, sensorB.value + delta))
113 } else if (sensor === 'C') {
114 sensorC.value = Math.max(0, Math.min(100, sensorC.value + delta))
115 }
116}
117
118function toggleMonitoring() {
119 isMonitoring.value = !isMonitoring.value
120}
121
122function resetSensors() {
123 sensorA.value = 75
124 sensorB.value = 82
125 sensorC.value = 91
126}
127
128function clearAlerts() {
129 alerts.value = []
130 sensorReadings.value = []
131}
132
133// Cleanup on unmount
134onUnmounted(() => {
135 if (monitoringInterval) {
136 clearInterval(monitoringInterval)
137 }
138})
139</script>
140
141<template>
142 <div class="advanced-watch">
143 <div class="header">
144 <h1>NOVA LAB Sensor Array</h1>
145 <p class="subtitle">Multiple Sources & Watch Cleanup</p>
146 </div>
147
148 <div class="monitoring-control">
149 <button
150 @click="toggleMonitoring"
151 :class="{ active: isMonitoring }"
152 class="monitor-btn"
153 >
154 {{ isMonitoring ? '⏸Stop Monitoring' : '▶Start Monitoring' }}
155 </button>
156 <div class="status" :class="{ monitoring: isMonitoring }">
157 {{ isMonitoring ? 'ACTIVE' : 'INACTIVE' }}
158 </div>
159 </div>
160
161 <div class="sensors-grid">
162 <div class="sensor-card sensor-a">
163 <h3>Sensor A</h3>
164 <div class="sensor-visual">
165 <div
166 class="sensor-bar"
167 :style="{ height: sensorA + '%', background: '#00ff88' }"
168 ></div>
169 </div>
170 <div class="sensor-value">{{ sensorA.toFixed(1) }}%</div>
171 <div class="sensor-controls">
172 <button @click="adjustSensor('A', -5)">-5</button>
173 <button @click="adjustSensor('A', 5)">+5</button>
174 </div>
175 </div>
176
177 <div class="sensor-card sensor-b">
178 <h3>Sensor B</h3>
179 <div class="sensor-visual">
180 <div
181 class="sensor-bar"
182 :style="{ height: sensorB + '%', background: '#00b4d8' }"
183 ></div>
184 </div>
185 <div class="sensor-value">{{ sensorB.toFixed(1) }}%</div>
186 <div class="sensor-controls">
187 <button @click="adjustSensor('B', -5)">-5</button>
188 <button @click="adjustSensor('B', 5)">+5</button>
189 </div>
190 </div>
191
192 <div class="sensor-card sensor-c">
193 <h3>Sensor C</h3>
194 <div class="sensor-visual">
195 <div
196 class="sensor-bar"
197 :style="{ height: sensorC + '%', background: '#ffaa00' }"
198 ></div>
199 </div>
200 <div class="sensor-value">{{ sensorC.toFixed(1) }}%</div>
201 <div class="sensor-controls">
202 <button @click="adjustSensor('C', -5)">-5</button>
203 <button @click="adjustSensor('C', 5)">+5</button>
204 </div>
205 </div>
206
207 <div class="sensor-card average">
208 <h3>Average</h3>
209 <div class="avg-display">{{ avgReading }}%</div>
210 <div class="avg-status" :style="{
211 color: avgReading > 80 ? '#00ff88' : avgReading > 60 ? '#ffaa00' : '#ff0044'
212 }">
213 {{ avgReading > 80 ? 'OPTIMAL' : avgReading > 60 ? 'CAUTION' : 'CRITICAL' }}
214 </div>
215 </div>
216 </div>
217
218 <div class="quick-actions">
219 <button @click="resetSensors">Reset Sensors</button>
220 <button @click="clearAlerts">Clear Logs</button>
221 </div>
222
223 <div class="data-panels">
224 <div class="panel alerts-panel">
225 <h3>Alert Log</h3>
226 <div class="panel-content">
227 <div
228 v-for="(alert, index) in alerts.slice(0, 10)"
229 :key="index"
230 class="alert-item"
231 :style="{ borderLeftColor: alert.color }"
232 >
233 <span class="alert-time">[{{ alert.time }}]</span>
234 <span v-if="alert.sensor" class="alert-sensor">Sensor {{ alert.sensor }}:</span>
235 <span v-if="alert.value !== undefined" class="alert-value">
236 {{ alert.previous !== undefined ? `${alert.previous} → ` : '' }}{{ alert.value }}%
237 </span>
238 <span v-if="alert.message" class="alert-message">{{ alert.message }}</span>
239 </div>
240 <div v-if="alerts.length === 0" class="no-data">
241 No alerts yet...
242 </div>
243 </div>
244 </div>
245
246 <div class="panel readings-panel">
247 <h3>Sensor Readings (Auto-capture)</h3>
248 <div class="panel-content">
249 <div
250 v-for="(reading, index) in sensorReadings.slice(0, 8)"
251 :key="index"
252 class="reading-item"
253 >
254 <span class="reading-time">[{{ reading.time }}]</span>
255 <span class="reading-data">
256 A: {{ reading.a.toFixed(1) }}% |
257 B: {{ reading.b.toFixed(1) }}% |
258 C: {{ reading.c.toFixed(1) }}% |
259 Avg: {{ reading.avg }}%
260 </span>
261 </div>
262 <div v-if="sensorReadings.length === 0" class="no-data">
263 Start monitoring to capture readings...
264 </div>
265 </div>
266 </div>
267 </div>
268
269 <div class="info-box">
270 <strong>Advanced Watching:</strong> Watch multiple sources with <code>watch([a, b, c], ...)</code>.
271 Use <code>onCleanup()</code> to clean up side effects like intervals when watcher stops!
272 </div>
273 </div>
274</template>
275
276<style>
277* {
278 margin: 0;
279 padding: 0;
280 box-sizing: border-box;
281}
282
283body {
284 font-family: 'Courier New', monospace;
285 background: #0a0e27;
286 color: #00ff88;
287 padding: 20px;
288}
289
290.advanced-watch {
291 max-width: 1200px;
292 margin: 0 auto;
293}
294
295.header {
296 text-align: center;
297 margin-bottom: 30px;
298 padding: 20px;
299 background: linear-gradient(135deg, #1a1f4c 0%, #0a0e27 100%);
300 border: 2px solid #00ff88;
301 border-radius: 10px;
302}
303
304.header h1 {
305 font-size: 2rem;
306 margin-bottom: 10px;
307 text-shadow: 0 0 20px #00ff88;
308}
309
310.subtitle {
311 color: #00b4d8;
312 font-size: 1.1rem;
313}
314
315.monitoring-control {
316 display: flex;
317 justify-content: center;
318 align-items: center;
319 gap: 20px;
320 margin-bottom: 30px;
321}
322
323.monitor-btn {
324 padding: 15px 40px;
325 font-size: 1.2rem;
326 background: linear-gradient(135deg, #00b4d8 0%, #0077aa 100%);
327 color: white;
328 border: none;
329 border-radius: 8px;
330 cursor: pointer;
331 font-family: 'Courier New', monospace;
332 font-weight: bold;
333 transition: all 0.3s ease;
334}
335
336.monitor-btn.active {
337 background: linear-gradient(135deg, #00ff88 0%, #00cc66 100%);
338 animation: pulse 2s infinite;
339}
340
341@keyframes pulse {
342 0%, 100% { box-shadow: 0 0 20px rgba(0, 255, 136, 0.5); }
343 50% { box-shadow: 0 0 40px rgba(0, 255, 136, 0.8); }
344}
345
346.status {
347 font-size: 1.5rem;
348 font-weight: bold;
349 color: #666;
350}
351
352.status.monitoring {
353 color: #00ff88;
354 text-shadow: 0 0 10px #00ff88;
355}
356
357.sensors-grid {
358 display: grid;
359 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
360 gap: 20px;
361 margin-bottom: 20px;
362}
363
364.sensor-card {
365 background: rgba(0, 180, 216, 0.05);
366 border: 2px solid #00b4d8;
367 border-radius: 10px;
368 padding: 20px;
369 text-align: center;
370}
371
372.sensor-card h3 {
373 color: #00b4d8;
374 margin-bottom: 15px;
375}
376
377.sensor-visual {
378 height: 150px;
379 background: rgba(0, 0, 0, 0.5);
380 border: 2px solid #00ff88;
381 border-radius: 8px;
382 margin-bottom: 15px;
383 display: flex;
384 align-items: flex-end;
385 justify-content: center;
386 overflow: hidden;
387}
388
389.sensor-bar {
390 width: 60%;
391 transition: height 0.3s ease;
392 border-radius: 5px 5px 0 0;
393}
394
395.sensor-value {
396 font-size: 1.8rem;
397 font-weight: bold;
398 color: #00ff88;
399 margin-bottom: 10px;
400}
401
402.sensor-controls {
403 display: flex;
404 gap: 10px;
405 justify-content: center;
406}
407
408.sensor-controls button {
409 background: #00b4d8;
410 color: white;
411 border: none;
412 padding: 8px 15px;
413 border-radius: 5px;
414 cursor: pointer;
415 font-family: 'Courier New', monospace;
416 font-weight: bold;
417}
418
419.average {
420 grid-column: span 1;
421}
422
423.avg-display {
424 font-size: 3rem;
425 font-weight: bold;
426 color: #00ff88;
427 text-shadow: 0 0 20px #00ff88;
428 margin: 20px 0;
429}
430
431.avg-status {
432 font-size: 1.2rem;
433 font-weight: bold;
434}
435
436.quick-actions {
437 display: flex;
438 gap: 15px;
439 justify-content: center;
440 margin-bottom: 20px;
441}
442
443.quick-actions button {
444 padding: 12px 30px;
445 background: linear-gradient(135deg, #666 0%, #444 100%);
446 color: white;
447 border: none;
448 border-radius: 5px;
449 cursor: pointer;
450 font-family: 'Courier New', monospace;
451 font-weight: bold;
452}
453
454.data-panels {
455 display: grid;
456 grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
457 gap: 20px;
458 margin-bottom: 20px;
459}
460
461.panel {
462 background: rgba(0, 0, 0, 0.5);
463 border: 2px solid #00ff88;
464 border-radius: 10px;
465 padding: 20px;
466}
467
468.panel h3 {
469 color: #00ff88;
470 margin-bottom: 15px;
471}
472
473.panel-content {
474 max-height: 300px;
475 overflow-y: auto;
476}
477
478.alert-item,
479.reading-item {
480 padding: 10px;
481 margin-bottom: 8px;
482 background: rgba(0, 255, 136, 0.05);
483 border-radius: 4px;
484 font-size: 0.9rem;
485 animation: slideIn 0.3s ease;
486}
487
488.alert-item {
489 border-left: 4px solid;
490}
491
492@keyframes slideIn {
493 from {
494 opacity: 0;
495 transform: translateX(-10px);
496 }
497 to {
498 opacity: 1;
499 transform: translateX(0);
500 }
501}
502
503.alert-time,
504.reading-time {
505 opacity: 0.7;
506 margin-right: 8px;
507}
508
509.alert-sensor {
510 color: #00b4d8;
511 font-weight: bold;
512 margin-right: 5px;
513}
514
515.alert-value {
516 color: #00ff88;
517}
518
519.reading-data {
520 color: #00b4d8;
521}
522
523.no-data {
524 text-align: center;
525 padding: 30px;
526 color: #666;
527 font-style: italic;
528}
529
530.info-box {
531 background: rgba(0, 255, 136, 0.1);
532 border: 2px solid #00ff88;
533 border-radius: 10px;
534 padding: 15px;
535 text-align: center;
536 color: #00ff88;
537 line-height: 1.6;
538}
539
540code {
541 background: rgba(0, 0, 0, 0.3);
542 padding: 2px 8px;
543 border-radius: 3px;
544 color: #00b4d8;
545}
546</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. When is it better to use computed instead of watch?
2. When is it better to use watch instead of computed?
These are 2 of 10 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Complete the debounced search using watch to delay sending the query.
- Click in order
Arrange the correct import of computed and watch from Vue:
- Vertical ordering
Arrange the watch flush modes from earliest to latest execution:
- Horizontal ordering
Arrange the watch observing a reactive object property:
- Click in order
Arrange the watch observing multiple refs simultaneously:
- Vertical ordering
Arrange the steps for creating a computed property from start to finish:
- Horizontal ordering
Arrange the correct destructuring in watch on multiple sources:
- Click in order
Arrange watchEffect with onCleanup in the correct order:
- Horizontal ordering
Arrange the computed for email validation in the correct order:
- Vertical ordering
Arrange the steps for implementing debounce with watch from start to finish:
- Click in order
Arrange the computed dependency chain from source to final value:
- Horizontal ordering
Arrange the correct watchPostEffect call: