So far you have seen the same Nómada Tasks screen written in plain JavaScript and in React, and both versions shared one model: a function that runs again in full and an engine that compares the result with the previous one. Vue changes that at the root. Its reactivity is fine-grained: it does not compare trees, it records which expression reads which piece of data and updates only what depends on what changed. It is 10-01's family 2, in its most polished and approachable form. And it also changes the unit of work: instead of splitting a card across a <template>, a CSS file and two JavaScript modules, Vue groups the whole thing into a Single-File Component, with its template, its logic and its scoped styles. In this lesson you will see why it is called a progressive framework and how it is added both to an existing page and to a complete project; the anatomy of a component with <template>, <script setup> and <style scoped>; the whole template syntax —interpolation, v-bind, v-on, v-if versus v-show, v-for with :key (the same old data-id) and v-model explained as sugar rather than magic—; reactivity properly, with ref, reactive, why .value exists, how proxies detect dependencies and where reactivity gets lost —the most disconcerting mistake—; computed compared with your version cache from 09-02 and with useMemo; watch and watchEffect with the criterion for choosing; the lifecycle with onMounted and onUnmounted, which is once again your destroy(); communication with props, emits, slots and provide/inject; composables as the equivalent of custom hooks; Pinia in its minimal form; and the official ecosystem. At the end, the task list with its filter reimplemented in Vue and compared with the two previous versions.
Contents
- What "progressive framework" means
- Adding Vue to an existing page
- A complete project with Vite
- Single-File Components
- Why group template, logic and styles
- The declarative template: interpolation
v-bind: dynamic attributesv-on: eventsv-if,v-elseandv-showv-forand:keyv-model: two-way binding explained as sugar- Reactivity:
refand why.valueexists reactiveand when to use each one- How proxies detect dependencies
- The limits of reactivity: where it gets lost
computed: derived values with a cachewatchandwatchEffect- When to use
computed,watchorwatchEffect - The lifecycle:
onMountedandonUnmounted - Communication:
propsandemits slots: content compositionprovideandinject- Composables:
useBoardanduseAssigneeFilter - Global state with Pinia
- The official ecosystem and fragmentation
- Nómada Tasks in Vue: the complete list
- Comparison with React and with plain JavaScript
- Common Mistakes and Tips
- Exercises
- Conclusion
- What "progressive framework" means
Vue describes itself as a progressive framework, and that label has a concrete technical meaning: it can be adopted in layers, starting with the smallest one, without committing to the rest.
| Adoption level | What you use | What you need |
|---|---|---|
| 1 · Enhancing an existing page | Vue from a <script> tag, controlling a <div> |
Nothing: no build step, no npm |
| 2 · Components across several pages | Vue + Single-File Components | Vite |
| 3 · Single-page application | + Vue Router | Vite |
| 4 · Large application | + Pinia, testing, TypeScript | The full toolchain |
| 5 · Server rendering | + Nuxt | The meta-framework |
That gradation does not exist in Angular, which demands the whole platform from the start, and in React it only half exists (you need a build step for JSX from minute one, unless you give it up). It is the reason Vue shows up so often in projects that started with no framework and grew: they can be converted piece by piece.
For Nómada Tasks that means something very concrete. You could take the current application —your index.html, your Board, your utilities— and convert only the task list into a Vue component, leaving the rest untouched. It is not a theoretical exercise: it is a real migration strategy, and you will pick it up again in 10-06.
- Adding Vue to an existing page
Level 1, with no tooling at all:
<div id="board">
<label>
Assignee:
<select v-model="assignee">
<option :value="null">All</option>
<option v-for="n in assignees" :key="n" :value="n">{{ n }}</option>
</select>
</label>
<p>{{ visible.length }} of {{ tasks.length }} tasks · {{ openHours }} h open</p>
<ul>
<li v-for="task in visible" :key="task.id">
{{ task.title }} — {{ task.assignee }} ({{ task.estimatedHours }} h)
</li>
</ul>
</div>
<script type="module">
import { createApp, ref, computed } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
import { BACKLOG } from './js/data/backlog.js';
createApp({
setup() {
const tasks = ref(BACKLOG);
const assignee = ref(null);
const assignees = computed(() =>
[...new Set(tasks.value.map((t) => t.assignee).filter(Boolean))].sort()
);
const visible = computed(() =>
assignee.value === null
? tasks.value
: tasks.value.filter((t) => t.assignee === assignee.value)
);
const openHours = computed(() =>
visible.value.filter((t) => t.status !== 'done')
.reduce((s, t) => s + t.estimatedHours, 0)
);
return { tasks, assignee, assignees, visible, openHours };
}
}).mount('#board');
</script>It is worth pausing here, because this block already contains almost every concept in the lesson and it works by opening the HTML in the browser, with no npm install, no build step and no configuration. Notice three things:
- The template is valid HTML.
v-for,:valueand{{ }}are attributes and text; the browser ignores them until Vue interprets them. This has an important practical consequence: the markup can be written in any HTML editor, and a designer understands it. - Vue only controls
#board. The rest of the page is still yours. You could have three independent Vue islands on the same page alongside your usual JavaScript. BACKLOGis your data module, unadapted. Vue has no opinion about where the objects come from.
For production you would use a compiled build instead of the one that interprets templates in the browser, but the mental model is this one.
- A complete project with Vite
For the rest of the lesson we will use level 2 and beyond:
The wizard asks whether you want TypeScript, Vue Router, Pinia, testing with Vitest and Playwright or Cypress. That list is revealing: the whole essential ecosystem is official, maintained by the same team and with integrated documentation. It is the opposite of 10-02's decision table.
The entry point:
// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import './styles.css';
createApp(App).mount('#root');
- Single-File Components
The .vue file is Vue's unit of work: template, logic and styles in the same place.
<!-- src/components/TaskCard.vue -->
<script setup>
import { computed } from 'vue';
import { NEXT, LABEL, isOverdue } from '../domain/rules.js';
const props = defineProps({
task: { type: Object, required: true }
});
const emit = defineEmits(['advance']);
const overdue = computed(() => isOverdue(props.task));
const next = computed(() => NEXT[props.task.status]);
</script>
<template>
<li
class="task"
:class="[
`task--${task.priority}`,
{ 'task--done': task.status === 'done', 'task--overdue': overdue }
]"
:data-id="task.id"
>
<h3 class="task__title">{{ task.title }}</h3>
<p class="task__meta">
{{ task.assignee ?? 'unassigned' }} · {{ task.estimatedHours }} h
<span v-if="overdue" class="task__warning"> · ⚠ overdue</span>
</p>
<ul class="task__tags">
<li v-for="tag in task.tags" :key="tag" class="tag">
{{ tag }}
</li>
</ul>
<button
type="button"
:disabled="next === null"
:aria-label="`${LABEL[task.status]}: ${task.title}`"
@click="emit('advance', task.id)"
>
{{ LABEL[task.status] }}
</button>
</li>
</template>
<style scoped>
.task { border-left: 4px solid var(--border); padding: 0.75rem; }
.task--high { border-left-color: var(--red); }
.task--done .task__title { text-decoration: line-through; opacity: 0.6; }
.task--overdue { background: var(--warning-soft); }
.task__tags { list-style: none; display: flex; gap: 0.3rem; padding: 0; }
</style>Three blocks, with rules of their own:
<script setup>is compile-time sugar. Everything you declare at the top level —variables, functions, imports— becomes automatically available in the template, with noreturn. It is the current and recommended way of writing Vue components; you will see older code withexport default { setup() { … return { … } } }or withdata()/methods(the Options API), which still work but are no longer the first choice.<template>contains HTML with directives. It is compiled into a render function during the build.<style scoped>applies the styles only to this component. The compiler adds a unique attribute to the component's elements and another to the selectors, so that the.taskhere cannot affect a.tasksomewhere else.
- Why group template, logic and styles
That scoped deserves a paragraph of its own, because it is where Vue solves 10-01's problem 7 more directly than React.
Remember your card in Nómada Tasks: the <template id="task-template"> in index.html, the .task__* classes in css/styles.css, the logic in js/view/card.js and the behavior in js/view/controller.js. Four files, four couplings and not one of them checked. Renaming .task__title in the CSS produces no error: the card just looks wrong.
With a .vue file, all four are visible on the same screen. Renaming a class is a find-and-replace operation inside a 40-line file. And since the styles are scoped, there is no need to invent naming conventions (BEM and the like) to avoid collisions: the isolation is real, not a discipline.
The classic objection —"mixing HTML, CSS and JavaScript is bad practice"— confuses two things. Separation of concerns is not separation of technologies: it is separation of responsibilities. A card's structure, appearance and behavior are one single concern, and splitting them across three files does not decouple them, it only moves them apart. It is exactly the argument from 10-01's section 12 about the component as the correct unit of reuse.
- The declarative template: interpolation
Dynamic text is written with double braces:
<h3>{{ task.title }}</h3>
<p>{{ task.estimatedHours }} h · {{ task.estimatedHours * WEIGHTS[task.priority] }} of effort</p>
<p>{{ task.assignee ?? 'unassigned' }}</p>Inside goes a JavaScript expression, not a statement — the same rule as JSX's braces. And the content is always escaped: it is textContent, not innerHTML, with the same protection as in 06-02.
A difference from React worth fixing: in JSX you write {} for everything dynamic, attributes included. In Vue, the braces are for text only; attributes use v-bind.
v-bind: dynamic attributes
v-bind: dynamic attributes<!-- Long form and shorthand, identical -->
<li v-bind:data-id="task.id">…</li>
<li :data-id="task.id">…</li>
<button :disabled="next === null">…</button>
<img :src="task.image" :alt="`Photo of ${task.title}`">Two special cases that are used constantly:
Classes. :class accepts a string, an object (key = class, value = condition) or an array, and it combines with the static class attribute:
<li
class="task"
:class="[
`task--${task.priority}`,
{ 'task--done': task.status === 'done', 'task--overdue': overdue }
]"
>Compare with what you did in paintCard:
li.classList.remove(...PRIORITY_CLASSES);
li.classList.add(`task--${task.priority}`);
li.classList.toggle('task--done', task.status === 'done');
li.classList.toggle('task--overdue', overdue);Four imperative statements that have to be kept in sync —including the preceding remove, which is easy to forget— versus one declarative description. And compared with React, where the string has to be built by hand with templates or a utility, Vue's object syntax is noticeably cleaner.
Styles. :style accepts an object with camelCase properties:
v-on: events
v-on: events<!-- Long form and shorthand -->
<button v-on:click="advance(task.id)">Start</button>
<button @click="advance(task.id)">Start</button>
<!-- With the event object -->
<select @change="onChange($event.target.value || null)">
<input @input="text = $event.target.value">The visible difference from React is that in the template you can write the call directly (advance(task.id)), without wrapping it in an arrow function. The compiler takes care of it: @click="advance(task.id)" becomes a handler that runs that expression. With @click="advance" (no parentheses) the function itself is passed and receives the event as its argument.
Vue adds modifiers, which declaratively solve things you write by hand in plain JavaScript:
| Modifier | Plain JavaScript equivalent |
|---|---|
@submit.prevent |
event.preventDefault() |
@click.stop |
event.stopPropagation() |
@click.self |
if (event.target !== event.currentTarget) return |
@click.once |
addEventListener(…, { once: true }) |
@scroll.passive |
addEventListener(…, { passive: true }) |
@keyup.enter |
if (event.key === 'Enter') |
@keyup.esc |
if (event.key === 'Escape') |
All of them will look familiar from 06-03 and 06-07. They are shortcuts, not new functionality, but they eliminate a whole class of oversights — above all forms' preventDefault.
A note about delegation: as in React, writing @click on each of 600 elements does not create 600 DOM listeners. The compiler generates handlers and Vue manages them efficiently. The closest('[data-action]') pattern from 06-04 is still valid if you need it, but it is no longer mandatory.
v-if, v-else and v-show
v-if, v-else and v-show<p v-if="visible.length === 0" class="empty-list">
No task matches the filter.
</p>
<ul v-else class="task-list">
<li v-for="task in visible" :key="task.id">…</li>
</ul>And the comparison you need to be clear about:
v-if |
v-show |
|
|---|---|---|
| What it does | Creates or destroys the DOM element | Always creates it and toggles display: none |
| Cost when toggling | High: mounting and unmounting | Very low: one CSS property |
| Initial cost if false | Zero: nothing is created | It is created anyway |
| Component lifecycle | onMounted/onUnmounted fire |
Nothing fires |
| Nodes in the DOM | Only the visible ones | All of them, always |
| Findable with Ctrl+F | No | Yes (it is in the DOM, hidden) |
| When to use it | A condition that changes rarely, or expensive content | Very frequent toggling of something lightweight |
This table is the formalization of something you already measured in 09-04: hiding 594 tasks with hidden kept 7,812 nodes in the DOM, while removing them left 1,194. v-if is the latter, v-show the former. For the assignee filter, v-if is the right choice.
Vue also lets you group things without adding a node:
<template v-if="loading">
<p role="status">Loading tasks…</p>
<div class="skeleton" aria-hidden="true"></div>
</template>It is the equivalent of React's <>…</> fragment.
v-for and :key
v-for and :key<ul class="task-list">
<li v-for="task in visible" :key="task.id" class="task">
{{ task.title }}
</li>
</ul>
<!-- With an index -->
<li v-for="(task, index) in visible" :key="task.id">
{{ index + 1 }}. {{ task.title }}
</li>
<!-- Over an object -->
<li v-for="(count, status) in statusTotals" :key="status">
{{ status }}: {{ count }}
</li>And here it is, for the third time in the module, the same concept: :key is your data-id from reconcile (06-06) and React's key (10-02). Vue uses it in exactly the same way: it builds a map of key to node, pairs by key and not by position, reuses the ones that are still alive and removes the leftovers.
The same three rules: stable, unique among siblings, never the index if the list moves. And a small but useful Vue advantage: the official plugin's ESLint rule flags a v-for without :key as an error, whereas in React it is only a console warning. It is a decision of a framework "with more opinions", in 10-01's sense.
One detail Vue does better than a pure virtual DOM: since the compiler analyzes the template, it knows which parts are static and skips them entirely on updates. If your <li> has ten elements and only one depends on data, Vue updates one. React runs the whole function and compares all ten. It is 10-01's blend of families 2 and 3 in action.
v-model: two-way binding explained as sugar
v-model: two-way binding explained as sugarv-model is Vue's most famous feature and the most misunderstood. It is presented as "two-way binding" and it sounds like bidirectional magic. It is not: it is syntactic sugar for two things you already know how to do.
compiles, essentially, into:
That is: a v-bind that brings the value down and a v-on that sends it up. Exactly 10-02's controlled form, written in one line instead of two. There is no magic channel and no DOM observation: there is an attribute and an event.
Vue adapts the pair to the element, which is what really saves work:
| Element | Property it binds | Event it listens to |
|---|---|---|
<input type="text"> |
value |
input |
<textarea> |
value |
input |
<input type="checkbox"> |
checked |
change |
<input type="radio"> |
checked |
change |
<select> |
value |
change |
<select multiple> |
an array of values | change |
With useful modifiers:
<input v-model.trim="title"> <!-- applies .trim(): useful for R2 -->
<input v-model.number="hours"> <!-- converts to a number: without it, "6" is a string -->
<input v-model.lazy="search"> <!-- listens to `change` instead of `input` -->v-model.number deserves a note, because it connects with 01-07: without it, <input type="number"> returns a string, and hours > 40 would compare text. It is one of the most frequent mistakes when validating R3.
In your own components, v-model works over a prop and an event:
<!-- Child component: AssigneeFilter.vue -->
<script setup>
defineProps({ modelValue: { type: String, default: null } });
defineEmits(['update:modelValue']);
</script>
<template>
<select :value="modelValue" @change="$emit('update:modelValue', $event.target.value || null)">
<option value="">All</option>
<slot />
</select>
</template>Once again: a prop coming down and an event going up. The data flow is still unidirectional; v-model only puts a name to the pattern. Understanding it that way avoids the misconception that the child modifies the parent's state — it does not: it asks the parent to modify it.
- Reactivity:
ref and why .value exists
ref and why .value existsHere we reach Vue's heart.
import { ref } from 'vue';
const assignee = ref(null);
console.log(assignee.value); // null
assignee.value = 'Iván'; // ← this triggers the update of everything that depends on itThe question everybody asks at the start: why .value?
The answer lies in a limitation of the language you have known since 01-05: JavaScript does not allow assignment to a variable to be intercepted. There is no way for assignee = 'Iván' to run code. You can intercept access to an object's property —that is what 05-03's getter/setter and Proxy do— but not to a standalone variable.
So Vue does the only possible thing: ref(null) returns an object with a single property, value, whose get and set are intercepted.
// What a ref is, conceptually (05-03: getters and setters)
function ref(initial) {
let internal = initial;
const subscribers = new Set();
return {
get value() {
trackDependency(subscribers); // ← whoever reads, signs up
return internal;
},
set value(next) {
if (Object.is(internal, next)) return; // no change, no work
internal = next;
notify(subscribers); // ← whoever read, finds out
}
};
}That .value that looks like a nuisance is, literally, the price of the mechanism existing. And there is an important compensation: in the template it is unwrapped automatically. Inside <template> you write {{ assignee }}, not {{ assignee.value }}, because the compiler knows which variables are refs and adds the .value for you.
A direct comparison with what you already know:
React's useState |
Vue's ref |
|
|---|---|---|
| Reading | assignee |
assignee.value (in the template: assignee) |
| Writing | setAssignee('Iván') |
assignee.value = 'Iván' |
| What it triggers | Re-running the whole component | Updating only what reads that ref |
| Can be written from outside the component | No | Yes: a ref is an ordinary object |
| Change comparison | Object.is on the value |
Object.is on the value |
The third row is the fundamental difference. Changing assignee.value does not re-run App: it re-runs the specific expressions that read that ref. That is 10-01's fine grain.
And the fourth has a very useful practical consequence: a ref can be created in a module, exported and modified from anywhere —including your BoardChannel from 07-04 when it receives a WebSocket message— without being inside any component.
reactive and when to use each one
reactive and when to use each onereactive is the alternative for objects: it wraps the whole object in a Proxy and needs no .value.
import { reactive } from 'vue';
const filters = reactive({ assignee: null, text: '', sort: 'priority' });
filters.assignee = 'Iván'; // no .value: reactive directly
filters.text = 'silkscreen';It is more convenient, but it has four real limitations:
| Limitation | Consequence |
|---|---|
It only works with objects, arrays, Map and Set |
A number or a string cannot be reactive |
| The whole object cannot be replaced | filters = {…} breaks reactivity; you have to assign property by property |
| It is lost when destructuring | const { assignee } = filters copies the value, not the link |
| It is lost when passing a primitive property to a function | The value is passed, not the reference |
The current recommendation, and the one almost all modern Vue code follows: use ref by default. It works with any type, it can be replaced wholesale, and the explicit .value makes it visible where the reactivity is. reactive is left for grouped-state objects whose properties are manipulated one by one, and even then many teams prefer a ref with an object inside:
const filters = ref({ assignee: null, text: '' });
filters.value.assignee = 'Iván'; // reactive
filters.value = { assignee: null, text: '' }; // full replacement, also reactive
- How proxies detect dependencies
Time to explain the mechanism properly, because all the behaviors —the good ones and the odd ones— come out of it.
While running a reactive effect (a component's render, a computed, a watchEffect), Vue keeps a reference to the current effect. When during that execution a reactive property is read, the Proxy intercepts the read and records: "this effect depends on this property of this object". When somebody writes that property, the proxy looks up the list of dependent effects and runs them again.
graph TD E["Reactive effect<br/>(render, computed, watchEffect)"] --> L["Reads task.status"] L --> P["The proxy intercepts the get"] P --> R["Records: the effect depends on<br/>(taskObject, 'status')"] W["Somebody writes task.status = 'done'"] --> P2["The proxy intercepts the set"] P2 --> B["Looks up the effects that depend<br/>on (taskObject, 'status')"] B --> E2["Runs them again"]
Three consequences that explain everything else:
1 · Detection is automatic and precise. You do not declare dependencies as in useEffect's array: they are discovered by running. That wipes out, in one stroke, the whole class of "a dependency is missing" and "this dependency always changes" errors from 10-02.
2 · Detection is dynamic. If a computed has an if, the dependencies of the branch not taken are not registered in this execution. That is correct and it is what you want: if the result does not depend on a piece of data, changing that data should recompute nothing.
3 · Detection only works during the effect's execution. If you read a reactive value inside a setTimeout, a .then() or an addEventListener, that read happens afterwards, when the effect has already finished and registration is closed. Nothing is recorded. This is the cause of half of Vue's "this does not update" cases.
// ❌ The read happens outside the tracking context
watchEffect(() => {
setTimeout(() => {
console.log(assignee.value); // registers no dependency
}, 100);
});
// ✅ The read happens inside
watchEffect(() => {
const current = assignee.value; // ← it is registered here
setTimeout(() => console.log(current), 100);
});
- The limits of reactivity: where it gets lost
This section is what separates whoever knows Vue from whoever has tried it. Reactivity is lost in four situations, and they all have the same cause: the link lives in the property, not in the value.
Limit 1 · Destructuring a reactive object.
const filters = reactive({ assignee: null, text: '' });
// ❌ `assignee` is a copy of the value, with no link
const { assignee } = filters;
console.log(assignee); // null, and it will stay null forever
// ✅ toRefs keeps the link by turning each property into a ref
import { toRefs } from 'vue';
const { assignee: assigneeRef } = toRefs(filters);
console.log(assigneeRef.value); // still linkedtoRefs walks the object and creates, for each property, a ref whose get/set point at the original property. It is the specific tool for destructuring without breaking anything, and it is used a lot when returning state from a composable.
Limit 2 · Destructuring the props.
const props = defineProps({ task: Object });
const { task } = props; // ❌ loses reactivity
// ✅ use props.task directly, or toRefs(props)With an important nuance: <script setup> has a compiler transform that makes props destructuring written directly in defineProps reactive. Outside that specific case, the rule stands.
Limit 3 · Passing a primitive to a function.
const count = ref(0);
function increment(value) { value++; } // ❌ receives a copy of the number
increment(count.value); // does nothing
function incrementRef(ref) { ref.value++; } // ✅ receives the ref object
incrementRef(count);This is the deep reason a ref is an object: objects are passed by reference and primitives by value, exactly as you learned in 01-05 and 04-08. ref turns a primitive into something that can be passed around without losing it.
Limit 4 · Replacing a whole reactive object.
let filters = reactive({ assignee: null });
filters = reactive({ assignee: 'Iván' }); // ❌ the template still points at the first oneThe honest comparison with React, which is the trade-off 10-01 announced:
| React | Vue | |
|---|---|---|
| Declaring dependencies | By hand, in an array | Automatic, on read |
| Typical mistake | A missing dependency or an object that always changes | Reactivity lost on destructuring or on reading outside the effect |
| When the mistake is caught | ESLint warns | At runtime, with no warning: "it does not update" |
| Mental model | Simple, verbose | Subtle, concise |
Neither of the two is "easier". React forces you to declare and that is why it can check it with an ESLint rule; Vue does it by itself and that is why the failure is silent. It is a real trade.
computed: derived values with a cache
computed: derived values with a cachecomputed declares a value derived from other reactive values:
import { ref, computed } from 'vue';
const tasks = ref(BACKLOG);
const assignee = ref(null);
const visible = computed(() =>
assignee.value === null
? tasks.value
: tasks.value.filter((t) => t.assignee === assignee.value)
);
const openHours = computed(() =>
visible.value.filter((t) => t.status !== 'done')
.reduce((s, t) => s + t.estimatedHours, 0)
);Four properties:
- It is cached. The computation only runs if some dependency changed. Reading
visible.valuea hundred times with no changes runs the filter once. - It composes.
openHoursdepends onvisible, which depends ontasksandassignee. Vue builds the graph and propagates in the right order, recomputing nothing twice. - It is lazy. If nobody reads
openHours, it is not computed, even iftaskschanges. - It is read-only by default (a
setcan be defined, but it is rare and almost always indicates a design that could be improved).
And here is the parallel that closes 09-02:
// Your version cache from 09-02
summary(today = TODAY) {
if (this.#summaryCache?.version === this.#version && this.#summaryCache.today === today) {
return this.#summaryCache.value;
}
const value = this.#computeSummary(today);
this.#summaryCache = { version: this.#version, today, value };
return value;
}You wrote twenty lines —a version counter, invalidation on every mutation, comparison of the cache key— and the permanent risk of forgetting an #invalidate(). computed does the same thing, with the invalidation discovered automatically and no possibility of forgetting.
A comparison with React's useMemo, which is the closest equivalent:
useMemo (React) |
computed (Vue) |
|
|---|---|---|
| Dependencies | Declared by hand | Detected automatically |
| Risk of error | Forgetting a dependency | Reading outside the tracking context |
| When it is used | As an optimization, when you measure that it hurts | As the natural way of writing derived values |
| Can be used outside a component | No | Yes |
| Cost if unnecessary | Comparing the dependency array | Minimal |
The third row is the cultural difference between the two frameworks. In React, useMemo is the exception and computing directly is the norm. In Vue, computed is the norm: any derived value is declared that way, without thinking about performance, because it is the correct way of expressing it. And that is why in Vue you almost never see 10-02's debate about excessive memoization.
watch and watchEffect
watch and watchEffectBoth perform side effects when something changes. They are the equivalent of useEffect, with the same warning: they are not for deriving values —that is what computed is for.
watch observes a specific source and receives the new value and the previous one:
import { watch } from 'vue';
watch(assignee, (next, previous) => {
console.log(`Filter: ${previous} → ${next}`);
updateUrl({ assignee: next }); // History API, 07-06
});
// Several sources
watch([assignee, text], ([a, t]) => savePreferences({ a, t }));
// A getter, to observe a specific property
watch(() => props.task.status, (next) => {
if (next === 'done') announce(`${props.task.title} completed`);
});
// With options
watch(assignee, loadTasks, { immediate: true }); // it also runs at the start
watch(filters, save, { deep: true }); // observes nested changeswatchEffect runs the function immediately and discovers its dependencies on its own, like a computed but for effects:
import { watchEffect } from 'vue';
watchEffect(() => {
document.title = `Nómada Tasks (${pending.value})`; // depends on `pending`
});And both accept a cleanup, which is once again your destroy():
watchEffect((onCleanup) => {
const controller = new AbortController();
listTasks({ assignee: assignee.value, signal: controller.signal })
.then((data) => { tasks.value = data; })
.catch((err) => { if (err.name !== 'AbortError') failure.value = err; });
onCleanup(() => controller.abort()); // runs before the next one and on unmount
});That block solves 10-02's race condition with the same AbortController from 07-03, and in Vue there is no [assignee] to declare in any array: the watchEffect signed itself up as a dependent when it read assignee.value.
- When to use
computed, watch or watchEffect
computed, watch or watchEffectThe table that avoids 90% of Vue's reactivity mistakes:
| You need… | Use | Why |
|---|---|---|
| A value derived from others | computed |
Cached, lazy, no side effects |
| To react to a specific change knowing the previous value | watch |
It gives you next and previous, and it is explicit |
| To sync with something external using several sources | watchEffect |
It detects the dependencies by itself |
| To run something only on mount | onMounted |
It is a moment in the lifecycle, not a change |
To store a derived value in a ref with watch |
Nothing: use computed |
It is the antipattern equivalent to 10-02's |
That last case deserves to be written out, because it is exactly the mistake from 10-02's section 16 translated into Vue:
// ❌ WRONG: a watch to derive
const openHours = ref(0);
watch(tasks, (t) => {
openHours.value = t.filter((x) => x.status !== 'done')
.reduce((s, x) => s + x.estimatedHours, 0);
}, { immediate: true });
// ✅ RIGHT
const openHours = computed(() =>
tasks.value.filter((t) => t.status !== 'done')
.reduce((s, t) => s + t.estimatedHours, 0)
);The same three defects: two sources of truth, a moment when the value is out of date, and more code. The rule transfers between frameworks: if it is a value, declare it as derived; if it is an action, do it in an effect.
- The lifecycle:
onMounted and onUnmounted
onMounted and onUnmountedimport { onMounted, onUnmounted, onUpdated, ref } from 'vue';
const container = ref(null); // a template ref: it will point at the real node
onMounted(() => {
// The DOM already exists: here you can measure, focus, observe
const observer = new IntersectionObserver(onEnter); // 07-06
observer.observe(container.value);
onUnmounted(() => observer.disconnect()); // ← the cleanup
});The main moments, with their equivalences:
| Vue | React | Your Nómada Tasks |
|---|---|---|
onMounted |
useEffect(…, []) |
BoardView's constructor + first render() |
onUpdated |
Running the component | update() |
onUnmounted |
useEffect's return |
destroy() (09-03) |
onErrorCaptured |
Error boundary | 02-05's try/catch in the controller |
The third row is once again the important one, and 10-01's message still holds: Vue gives you the guaranteed moment, it does not guess what to switch off. A setInterval with no clearInterval in onUnmounted is exactly the same leak as in plain JavaScript.
A practical detail: a template ref (ref="container" in the HTML plus const container = ref(null) in the script) is the equivalent of React's useRef and of your old $('.task-list'). It is null until the component mounts, so it can only be used inside onMounted or later.
- Communication:
props and emits
props and emitsThe flow is the same as in React —data down, events up— with one difference: in Vue it is declared explicitly in both directions.
<script setup>
// Downward: props, with type, requiredness and validation
const props = defineProps({
task: {
type: Object,
required: true,
validator: (t) => typeof t.id === 'number' && typeof t.title === 'string'
},
compact: { type: Boolean, default: false }
});
// Upward: declared events, with optional validation
const emit = defineEmits({
advance: (id) => typeof id === 'number',
remove: (id) => typeof id === 'number'
});
function onAdvanceClick() {
emit('advance', props.task.id);
}
</script>Differences from React worth pointing out:
| React | Vue | |
|---|---|---|
| Data downward | Props (undeclared) | defineProps with type and validator |
| Events upward | Function props (onAdvance) |
defineEmits + emit('advance') |
| Validation in development | Only with TypeScript | Included in JavaScript |
| Data/event distinction | None: everything is a prop | Explicit in the API |
Declaring emits is valuable for a not-so-obvious reason: it documents the component's complete contract. By reading defineProps and defineEmits you know everything that goes in and everything that comes out, without searching the file for which functions are called. In React that information is scattered across the signature and has to be reconstructed.
And the same rule as in React: props are read-only. Modifying props.task.status from the child is a mistake, and Vue warns in development. The child emits; the parent decides.
slots: content composition
slots: content compositionSlots are the equivalent of React's children, and they are more expressive because they allow several named holes.
<!-- src/components/Panel.vue -->
<template>
<section class="panel">
<header class="panel__header">
<slot name="header">
<h2>Untitled</h2> <!-- default content if none is supplied -->
</slot>
</header>
<div class="panel__body">
<slot /> <!-- the default slot -->
</div>
<footer v-if="$slots.footer" class="panel__footer">
<slot name="footer" /> <!-- only painted if the parent supplied something -->
</footer>
</section>
</template><Panel>
<template #header>
<h2>Open tasks · {{ openHours }} h</h2>
</template>
<TaskList :tasks="visible" @advance="advance" />
<template #footer>
<button @click="clearFilters">Clear filters</button>
</template>
</Panel>And scoped slots, which let the child pass data to the content the parent supplies:
<!-- TaskList.vue: the list controls the loop, the parent decides how each row looks -->
<template>
<ul class="task-list">
<li v-for="task in tasks" :key="task.id">
<slot name="row" :task="task" :overdue="isOverdue(task)">
{{ task.title }}
</slot>
</li>
</ul>
</template><TaskList :tasks="visible">
<template #row="{ task, overdue }">
<strong :class="{ red: overdue }">{{ task.title }}</strong>
<em>{{ task.assignee }}</em>
</template>
</TaskList>It is a very powerful pattern: the component supplies the logic (the loop, the keys, the filtering) and whoever uses it supplies the presentation. In React the equivalent is done by passing a function as a prop, and it works just as well; the difference is one of syntax, not of capability.
provide and inject
provide and injectThe equivalent of React's Context: making a value available to the whole subtree.
// In an ancestor
import { provide, ref, readonly } from 'vue';
const theme = ref('light');
provide('theme', readonly(theme)); // read-only for the descendants
provide('toggleTheme', () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; });// In any descendant, at any depth
import { inject } from 'vue';
const theme = inject('theme', 'light'); // with a default value
const toggleTheme = inject('toggleTheme');And a technical difference from Context that matters: since Vue has fine-grained reactivity, providing a ref does not repaint everyone who does inject. Only those that actually read its .value are updated. Context's performance problem that 10-03 described —all consumers repaint when the value changes— simply does not exist here.
readonly is good practice: descendants read the value and, to change it, they call the provided function. That keeps control of the state where it belongs.
- Composables:
useBoard and useAssigneeFilter
useBoard and useAssigneeFilterA composable is a function that uses Vue's reactivity API and returns state and operations. It is the exact equivalent of 10-02's custom hooks, with one important difference: it has no call-order rules, because Vue does not identify state by position but by object.
// src/composables/useBoard.js
import { ref, computed } from 'vue';
import { NEXT, WEIGHTS } from '../domain/rules.js';
/** The state of the Nómada Tasks board and its operations. */
export function useBoard(initialTasks) {
const tasks = ref(initialTasks);
function advance(id) {
const task = tasks.value.find((t) => t.id === id);
if (!task) return;
const target = NEXT[task.status];
if (target === null) return; // R6: from 'done' there is no advancing
task.status = target; // direct mutation: it is reactive
}
const summary = computed(() => {
const open = tasks.value.filter((t) => t.status !== 'done');
return {
total: tasks.value.length,
totalHours: tasks.value.reduce((s, t) => s + t.estimatedHours, 0),
openHours: open.reduce((s, t) => s + t.estimatedHours, 0),
effort: tasks.value.reduce((s, t) => s + t.estimatedHours * WEIGHTS[t.priority], 0)
};
});
return { tasks, advance, summary };
}// src/composables/useAssigneeFilter.js
import { ref, computed } from 'vue';
export function useAssigneeFilter(tasks) {
const assignee = ref(null);
const assignees = computed(() =>
[...new Set(tasks.value.map((t) => t.assignee).filter(Boolean))].sort()
);
const visible = computed(() =>
assignee.value === null
? tasks.value
: tasks.value.filter((t) => t.assignee === assignee.value)
);
return { assignee, assignees, visible };
}Compare advance with its React equivalent from 10-02:
// React: immutable, necessarily
setTasks((current) => current.map((t) =>
t.id === id && NEXT[t.status] === target ? { ...t, status: target } : t
));
// Vue: direct mutation
task.status = target;This is the most visible practical difference between the two frameworks. In React immutability is mandatory because detection is by reference; in Vue the proxy detects the write to the property, so mutating is correct and natural. Neither is better: React wins on predictability —the state never changes under your feet and comparisons are trivial— and Vue wins on conciseness and on the fact that the 599 tasks that do not change are not even touched.
A note on naming: the Vue ecosystem uses the useSomething convention, exactly like React. That is what we follow here —useBoard and useAssigneeFilter— and it is what you will see in real code. What matters is that it be consistent.
- Global state with Pinia
Pinia is Vue's official store, and its minimal form is surprisingly similar to a composable:
// src/stores/board.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { NEXT } from '../domain/rules.js';
import { BACKLOG } from '../data/backlog.js';
export const useBoardStore = defineStore('board', () => {
// ── state ───────────────────────────────
const tasks = ref(BACKLOG);
const assignee = ref(null);
// ── derived values (the equivalent of "getters") ──
const visible = computed(() =>
assignee.value === null
? tasks.value
: tasks.value.filter((t) => t.assignee === assignee.value)
);
const openHours = computed(() =>
visible.value.filter((t) => t.status !== 'done')
.reduce((s, t) => s + t.estimatedHours, 0)
);
// ── actions ─────────────────────────────
function filterBy(name) { assignee.value = name; }
function advance(id) {
const task = tasks.value.find((t) => t.id === id);
if (task && NEXT[task.status] !== null) task.status = NEXT[task.status];
}
return { tasks, assignee, visible, openHours, filterBy, advance };
});<script setup>
import { storeToRefs } from 'pinia';
import { useBoardStore } from '../stores/board.js';
const store = useBoardStore();
const { visible, openHours } = storeToRefs(store); // ← storeToRefs, not plain destructuring
const { advance } = store; // functions can be destructured
</script>storeToRefs is indispensable and it is a direct application of section 15's limit 1: destructuring the store would copy the values and break reactivity. storeToRefs turns the state and the derived values into linked refs; the actions are ordinary functions and can be destructured with no problem.
Compared with Redux (10-03):
| Redux Toolkit | Pinia | |
|---|---|---|
| Concepts | Actions, reducers, selectors, middleware | State, derived values, actions |
| Immutability | Mandatory (with Immer to write it comfortably) | Not needed: you mutate |
| Traceability and time travel | Excellent | Good (Vue DevTools) |
| Approximate weight | ~13 kB | ~1.5 kB |
| Official for the framework | No | Yes |
| One store or several | One, split into slices | Several independent ones |
10-03's conclusion holds: start with no store. A composable with a ref in a module is already shared state —because a module-level ref is a single object— and it is often enough. Pinia adds DevTools, hot replacement, the guarantee of a single instance and support for server rendering. The difference from Redux is that the step up is much lower: moving from a composable to a Pinia store is ten minutes.
- The official ecosystem and fragmentation
| Piece | Vue (official) | React (to be chosen) |
|---|---|---|
| Routing | Vue Router | React Router, TanStack Router, the meta-framework's |
| Global state | Pinia | Redux Toolkit, Zustand, Jotai, Context |
| Bundling | Vite (created by the same author) | Vite, or the meta-framework's |
| Component testing | Vue Test Utils + Vitest | Testing Library + Jest/Vitest |
| Server rendering | Nuxt | Next.js, Remix, others |
| Development tools | Vue DevTools | React DevTools |
| Language | JavaScript or TypeScript | JavaScript or TypeScript |
That left-hand column explains why Vue is said to have less fragmentation, and it deserves an honest analysis in both directions.
Real advantages: there is a canonical answer to every question; the official documentation covers the whole set and the examples fit together; updates are coordinated; two Vue projects from different companies look fairly alike, which makes joining one faster; and you do not have to spend a week evaluating four routing libraries.
Real disadvantages: less innovation from outside, because there is less incentive to compete with the official solution; fewer options when your case is unusual; and a smaller third-party ecosystem in absolute numbers —for a very specific need you are more likely to find a React library than a Vue one.
It is 10-01's library-framework axis with Vue placed in the middle: more opinions than React, fewer than Angular. And here too it is worth distrusting automatic conclusions: React's fragmentation is a cost for a small team and an advantage for one that needs something uncommon.
- Nómada Tasks in Vue: the complete list
The module's target screen, third version. The domain is the same JavaScript file as in 10-02 and 10-03, untouched:
// src/domain/rules.js — identical across all three versions
export const NEXT = Object.freeze({ pending: 'in-progress', 'in-progress': 'done', done: null });
export const LABEL = Object.freeze({ pending: 'Start', 'in-progress': 'Mark done', done: 'Completed' });
export const WEIGHTS = Object.freeze({ high: 3, medium: 2, low: 1 });
export const TODAY = '2026-09-20';
export const isOverdue = (t, today = TODAY) => t.status !== 'done' && t.dueDate < today;The filter:
<!-- src/components/AssigneeFilter.vue -->
<script setup>
defineProps({
assignees: { type: Array, required: true },
modelValue: { type: String, default: null }
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="filter" for="assignee-filter">
Assignee:
<select
id="assignee-filter"
:value="modelValue ?? ''"
@change="$emit('update:modelValue', $event.target.value || null)"
>
<option value="">All</option>
<option v-for="name in assignees" :key="name" :value="name">
{{ name }}
</option>
</select>
</label>
</template>The card is the one from section 4. And the list:
<!-- src/components/TaskList.vue -->
<script setup>
import TaskCard from './TaskCard.vue';
defineProps({ tasks: { type: Array, required: true } });
defineEmits(['advance']);
</script>
<template>
<p v-if="tasks.length === 0" class="empty-list">
No task matches the filter.
</p>
<ul v-else class="task-list">
<TaskCard
v-for="task in tasks"
:key="task.id"
:task="task"
@advance="$emit('advance', $event)"
/>
</ul>
</template>
<style scoped>
.task-list { list-style: none; padding: 0; display: grid; gap: 0.5rem; }
.empty-list { color: var(--gray); font-style: italic; }
</style>And the application:
<!-- src/App.vue -->
<script setup>
import { useBoard } from './composables/useBoard.js';
import { useAssigneeFilter } from './composables/useAssigneeFilter.js';
import AssigneeFilter from './components/AssigneeFilter.vue';
import TaskList from './components/TaskList.vue';
import { BACKLOG } from './data/backlog.js';
import { computed } from 'vue';
const { tasks, advance, summary } = useBoard(BACKLOG);
const { assignee, assignees, visible } = useAssigneeFilter(tasks);
const visibleHours = computed(() =>
visible.value.filter((t) => t.status !== 'done')
.reduce((s, t) => s + t.estimatedHours, 0)
);
</script>
<template>
<main class="board">
<header class="board__header">
<h1>Nómada Tasks</h1>
<AssigneeFilter v-model="assignee" :assignees="assignees" />
<p class="board__summary">
{{ visible.length }} of {{ summary.total }} tasks · {{ visibleHours }} h open
</p>
</header>
<TaskList :tasks="visible" @advance="advance" />
</main>
</template>
<style scoped>
.board__header { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap; }
.board__summary { color: var(--gray); margin-left: auto; }
</style>The canonical numbers, verified: with no filter, "6 of 6 tasks · 45 h open". With "Iván", "3 of 6 · 25 h". With "Lucía", "1 of 6 · 14 h". With "Marta", "2 of 6 · 6 h". And on pressing "Start" on task 6, advance mutates task.status, the proxy notifies the computeds that read that property, and only the affected card and the summary are updated. The other five cards are not even re-run.
- Comparison with React and with plain JavaScript
The table of the three versions of the same screen:
| Concept | Plain JavaScript | React | Vue |
|---|---|---|---|
| Unit | view/*.js module + <template> + CSS |
.jsx function component |
.vue single-file component |
| Isolated styles | No (global classes) | Not by default | Yes (scoped) |
| State | state object + manual render() |
useState |
ref |
| Derived values | Version cache written by hand | useMemo (optional, to optimize) |
computed (the natural way) |
| Updating | render() by hand |
Re-running the component | Re-running only what reads the data |
| Immutability | Recommended | Mandatory | Not needed: you mutate |
| Identity in lists | data-id + reconcile() |
key |
:key |
| Cleanup | destroy() invoked by hand |
useEffect's return |
onUnmounted |
| Effect dependencies | — | An array declared by hand | Detected automatically |
| Events upward | CustomEvent |
A function prop | Declared emit |
| Content composition | <template> + web components' <slot> |
children |
Named and scoped slots |
| Reusable logic | Modules and classes | Hooks (with order rules) | Composables (no order rules) |
And the metrics for the same screen:
| Metric | Plain JavaScript | React | Vue |
|---|---|---|---|
| Lines of the view | ~210 | ~130 | ~120 |
| Lines of your own infrastructure | ~60 | 0 | 0 |
| View files | 5 (dom, card, board-view, controller, <template>) |
4 .jsx + 2 hooks |
4 .vue + 2 composables |
| Production dependencies | 0 | 2 | 1 |
| Engine weight (compressed, approx.) | 0 | ~45 kB | ~35 kB |
| Files to touch to add a piece of data to the card | 2 | 1 | 1 (its style included) |
| Work when the filter changes | Reconciling the list | Running 6 components + comparing | Updating only what changed |
And the qualitative balance, without declaring winners:
Vue wins on: conciseness (less ceremony for the same thing), isolated styles with no naming conventions, automatic reactivity that removes the dependency array, computed as the natural form rather than an optimization, a coherent official ecosystem and a gentler learning curve for anyone coming from HTML and CSS.
React wins on: the size of the job market and of the third-party ecosystem, a mental model that is simpler to explain (it is a function that re-runs), "everything is JavaScript" with no template syntax to learn, better third-party tooling support, and React Native for native mobile.
Plain JavaScript wins on: zero dependencies, zero engine weight, no mandatory build step, absolute control and code that will still work ten years from now with nothing updated.
Vue's trade-offs, stated clearly: automatic reactivity fails silently when it is lost (section 15), and the failure is "it does not update", which is harder to diagnose than an error; the template syntax is an extra language you have to learn and that generic tooling does not understand without plugins; and the job market is smaller than React's in most places, which is a legitimate criterion even if it is not a technical one.
Common Mistakes and Tips
Forgetting .value in the script. Mistake number one. In the template it is unwrapped automatically, in the script it is not. if (assignee === 'Iván') compares a ref object with a string and is always false, with no error. The official Vue plugin's ESLint rule catches it: enable it.
Destructuring a reactive object or a Pinia store. const { assignee } = filters copies the value and breaks the link. Use toRefs for reactive objects and storeToRefs for stores.
Reading reactive values outside the tracking context. Inside a setTimeout, a .then() or a handler registered by hand, the read is not recorded. Read the value beforehand, in the effect's body.
Using watch to derive values. It is the same antipattern as useEffect for deriving state in React: two sources of truth and a moment of lag. If it is a value, it is computed.
Confusing v-if with v-show. v-show leaves the nodes in the DOM: with 600 tasks, 600 hidden nodes, findable with Ctrl+F, with the memory and accessibility-tree cost you measured in 09-04. For filtering, v-if.
Using the index as :key. The same mistake as in React and as in your reconcile. If the list is filtered or reordered, nodes change data and focus and internal state are lost.
Forgetting .prevent on forms. @submit without .prevent reloads the page. It is so frequent that Vue made the modifier precisely for that.
Forgetting .number on numeric fields. <input type="number" v-model="hours"> stores a string. R3's validation (hours > 0 && hours <= 40) would compare text. Use v-model.number.
Mutating props from the child. Vue warns in development. The child emits an event; the parent decides what to do. It is the unidirectional flow, which v-model does not break but formalizes.
Believing that v-model is bidirectional magic. It is a v-bind plus a v-on. Understanding it that way avoids expecting behaviors that do not exist.
Tip: use ref by default and reactive only when you can justify it. ref works with any type, can be replaced wholesale, and the .value makes it visible where the reactivity is.
Tip: keep the domain outside the components. domain/rules.js is the same file in all three versions of this screen, and that is the best proof that 10-01's discipline works: changing framework only means rewriting the view.
Tip: install Vue DevTools from day one. It lets you inspect the component tree, see the value of every ref and every computed in real time, and —very usefully— see which dependencies each effect has registered, which is the direct way of diagnosing lost reactivity.
Exercises
Exercise 1 · Hunt the lost reactivity
This component has four reactivity flaws. Find them, explain why each one fails and write the corrected version.
<script setup>
import { ref, reactive, computed, watch } from 'vue';
import { BACKLOG } from '../data/backlog.js';
const filters = reactive({ assignee: null, text: '' });
const { assignee } = filters;
const tasks = ref(BACKLOG);
const openHours = ref(0);
watch(tasks, (t) => {
openHours.value = t.filter((x) => x.status !== 'done')
.reduce((s, x) => s + x.estimatedHours, 0);
}, { immediate: true });
const visible = computed(() => {
setTimeout(() => console.log(filters.text), 0);
return assignee === null
? tasks.value
: tasks.value.filter((t) => t.assignee === assignee);
});
function clear() {
filters = { assignee: null, text: '' };
}
</script>
<template>
<p>{{ visible.length }} tasks · {{ openHours }} h</p>
<button @click="clear">Clear</button>
</template>Exercise 2 · A composable with a lifecycle
Write a useRemoteTasks(assignee) composable that:
- Receives a
refwith the filtered assignee. - Loads the tasks from the server with your
listTasksfrom 07-02 every time the assignee changes. - Exposes
tasks,loadinganderroras refs. - Cancels the previous request when firing a new one and when the component unmounts, avoiding 10-02's race condition.
- Also exposes a
computedwith the open hours of the loaded tasks.
Then explain why this version needs no dependency array and which React mistake it avoids.
Exercise 3 · The same screen, three times
Without writing new code, fill in this table comparing the same functional change across the three versions of the screen you have seen in the module: adding a badge that shows each task's weighted effort (estimatedHours × WEIGHTS[priority]), with a red background if it exceeds 30.
| Step | Plain JavaScript | React | Vue |
|---|---|---|---|
| Files you have to open | |||
| Where the computation goes | |||
| Where the markup goes | |||
| Where the style goes | |||
| Risk of forgetting something | |||
| What is recomputed when the filter changes |
Then answer: in the plain-JavaScript version, what would happen if you added the badge to paintCard but forgot to add it to the <template> in index.html as well?
Solutions
Solution 1
Flaw 1 · const { assignee } = filters (line 6). Destructuring a reactive object copies the primitive value null and loses the link. assignee will be null forever, so visible will never filter anything even when the user changes the <select>. It is section 15's limit 1.
Flaw 2 · watch to derive openHours. It is section 18's antipattern: two sources of truth, a moment of lag and more code. On top of that, since tasks is a ref to an array and the watch is not deep, mutating a task's status does not fire the watch: openHours would go stale when marking a task as done, which is precisely the main use case.
Flaw 3 · A read inside a setTimeout in the computed. The read of filters.text happens after the computed has finished, outside the tracking context: it is not registered as a dependency. On top of that, a computed must have no side effects: it must be a pure function of its dependencies, just like your reducers from 10-03.
Flaw 4 · filters = { … } in clear. Reassigning a reactive object breaks the link with the template, which still points at the original proxy. Besides, filters is declared with const, so this would throw a TypeError at runtime (01-05).
Corrected version:
<script setup>
import { ref, computed } from 'vue';
import { BACKLOG } from '../data/backlog.js';
// ref by default: it works with any type and can be replaced wholesale
const filters = ref({ assignee: null, text: '' });
const tasks = ref(BACKLOG);
// Derived: computed, not watch
const openHours = computed(() =>
tasks.value.filter((t) => t.status !== 'done')
.reduce((s, t) => s + t.estimatedHours, 0)
);
// Pure and with every read inside the tracking context
const visible = computed(() => {
const { assignee, text } = filters.value; // read INSIDE: it is registered
const t = text.trim().toLowerCase();
return tasks.value
.filter((x) => assignee === null || x.assignee === assignee)
.filter((x) => t === '' || x.title.toLowerCase().includes(t));
});
function clear() {
filters.value = { assignee: null, text: '' }; // replacing the ref's contents
}
</script>
<template>
<p>{{ visible.length }} tasks · {{ openHours }} h</p>
<button @click="clear">Clear</button>
</template>A note about the destructuring on the line const { assignee, text } = filters.value: here it is correct, because it happens inside the computed. Reading filters.value registers the dependency at that moment; what is destructured afterwards are values that have already been read, used immediately. The rule is not "never destructure", it is "do not store destructured values outside a reactive context".
Solution 2
// src/composables/useRemoteTasks.js
import { ref, computed, watchEffect } from 'vue';
import { listTasks } from '../data/tasks-api.js'; // your module from 07-02
/**
* Loads the tasks from the server filtered by assignee.
* @param {import('vue').Ref<string|null>} assignee
*/
export function useRemoteTasks(assignee) {
const tasks = ref([]);
const loading = ref(false);
const error = ref(null);
watchEffect(async (onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort()); // ← before the next execution and on unmount
loading.value = true;
error.value = null;
try {
// Reading assignee.value registers the dependency HERE, before the await
tasks.value = await listTasks({
assignee: assignee.value,
signal: controller.signal
});
} catch (failure) {
if (failure.name === 'AbortError') return; // expected cancellation: not an error
error.value = failure; // ApiError from 07-03
} finally {
loading.value = false;
}
});
const openHours = computed(() =>
tasks.value.filter((t) => t.status !== 'done')
.reduce((s, t) => s + t.estimatedHours, 0)
);
return { tasks, loading, error, openHours };
}Usage:
<script setup>
import { ref } from 'vue';
import { useRemoteTasks } from './composables/useRemoteTasks.js';
const assignee = ref(null);
const { tasks, loading, error, openHours } = useRemoteTasks(assignee);
</script>
<template>
<p v-if="loading" role="status">Loading tasks…</p>
<p v-else-if="error" role="alert">Could not load: {{ error.message }}</p>
<ul v-else :aria-busy="loading">
<li v-for="t in tasks" :key="t.id">{{ t.title }}</li>
</ul>
<p>{{ openHours }} h open</p>
</template>Why no dependency array is needed. The watchEffect reads assignee.value during its execution, and the proxy registers that read automatically. When the value changes, the effect runs again — after having called the cleanup, which aborts the in-flight request.
Which React mistake it avoids. Two, in fact:
- The forgotten dependency. In React, writing
}, [])instead of}, [assignee])produces a component that loads once and never updates. It is so frequent that there is a specific ESLint rule for it. Here it is impossible by construction. - The dependency that always changes. If in React you passed an object
{ assignee }as a dependency, it would be recreated on every render and the request would fire endlessly. In Vue, the dependencies are the reactive values read, not references compared.
And a warning, so as not to oversell: there is a case where Vue's automatic tracking also fails. Reads that happen after an await are not registered, because the tracking context closes when the function suspends. That is why the code reads assignee.value before the await, inside the call. If you needed to read another ref after the await, you would have to read it beforehand and store it. It is the same limit as in section 15, in its subtlest form.
Solution 3
| Step | Plain JavaScript | React | Vue |
|---|---|---|---|
| Files you have to open | 3: index.html (<template>), js/view/card.js, css/styles.css |
2: TaskCard.jsx, the CSS |
1: TaskCard.vue |
| Where the computation goes | In paintCard, as a local variable |
In the component's body, computed during the render | In a computed in the <script setup> |
| Where the markup goes | Add a <span> to the <template> and fill it in inside paintCard |
In the JSX, next to the rest | In the <template> of the same file |
| Where the style goes | A new class in the global CSS, with a prefix to avoid collisions | A class in the global CSS, or a CSS module | In the <style scoped>, with no risk of collision |
| Risk of forgetting something | High: three files and a coupling by class name that nobody checks | Low | Very low: everything is on the same screen |
| What is recomputed when the filter changes | The visible cards are reconciled and each one's badge is recomputed | All 6 components run; with memo, only the ones that change |
Only the computeds whose dependencies changed |
What happens if you forget the <span> in index.html's <template>. It depends on how paintCard is written. If it uses $('.task__effort', li).textContent = …, the selector returns null and the line throws a TypeError: Cannot set properties of null at runtime, while painting the first card: the whole application breaks. If instead it checks first (const badge = $('.task__effort', li); if (badge) …), nothing visible happens: the badge simply does not appear, with no error at all, and the failure is only caught by looking at the screen or with an end-to-end test (08-06).
That is, named and identified, the unchecked coupling that 10-01's section 13 talked about. In the Vue version the mistake is impossible: the markup and the code that fills it in are the same file, and the template compiler warns if a variable does not exist.
Conclusion
You have seen Vue in its modern form —Composition API and <script setup>— and, above all, you have seen 10-01's second reactivity model genuinely at work.
You know what a progressive framework means and you have checked it: the same concepts serve to enhance an existing page with a <script> tag and no build step, or to put together a complete application with Vite, Vue Router and Pinia. You know Single-File Components with their three blocks and why grouping template, logic and styles does not violate separation of concerns but respects it: a card's structure, appearance and behavior are one single concern, and <style scoped> turns isolation into a compiler guarantee instead of a naming convention.
You are fluent in the template syntax: interpolation with its automatic escaping, v-bind with the object forms for classes and styles —four imperative classList statements reduced to one description—, v-on with its modifiers that encode 06-03's preventDefault and stopPropagation, the difference between v-if and v-show —create-and-destroy versus display: none, with the 7,812 nodes versus 1,194 you measured in 09-04—, v-for with :key, which for the third time in the module is your data-id from 06-06, and v-model demystified: a v-bind that brings the value down and a v-on that sends it up, 10-02's controlled form written in one line, with .trim and .number that prevent real mistakes in rules R2 and R3.
You understand reactivity from the inside. You know why .value exists —JavaScript does not allow assignment to a variable to be intercepted, only to a property— and that a ref is an object with intercepted get/set, exactly 05-03's mechanism. You know how proxies register dependencies during an effect's execution, with their three consequences: automatic and precise detection, dynamic detection that ignores branches not taken, and detection only during execution — from which come the four limits where reactivity is lost: destructuring a reactive object, destructuring props, passing a primitive to a function and replacing a whole reactive, with toRefs and storeToRefs as the tools. And you have the honest comparison: React forces you to declare dependencies and that is why it can check them with ESLint; Vue discovers them by itself and that is why the failure is silent.
You know that computed is your version cache from 09-02 with the invalidation discovered automatically —twenty lines and a risk of forgetting turned into one line with no risk— and that, unlike useMemo, it is not an optimization but the natural way of writing derived values. You tell watch and watchEffect apart with the criterion for choosing among the three, and you recognize the antipattern of deriving with watch as the same mistake as deriving with useEffect. You know the lifecycle with onMounted and onUnmounted, which is once again your destroy() with the call guaranteed but the content still your responsibility.
You know how to make components communicate with declared props and emits —a complete, validated contract that reads in two lines—, how to compose content with named and scoped slots, and how to inject values with provide/inject, which unlike Context does not repaint all the consumers because reactivity is fine-grained. You extract logic into composables with no call-order rules, and you know that the most visible practical difference from React is in advance: where React demands an immutable map, Vue mutates the property and the proxy does the rest. You know Pinia in its minimal form —almost identical to a composable, with storeToRefs as a requirement— and the official ecosystem that explains Vue's lower fragmentation, with its advantages and its costs.
And you have seen the third version of the same screen, with the same domain/rules.js untouched across all three, ~120 lines, one file per component with its isolated styles, and updates that touch only what changed. With the trade-offs stated: lost reactivity fails silently, the template syntax is one more language, and the job market is smaller.
Two models remain to be seen. The next one is the most different of all: a framework that is neither a library nor a progressive framework but a complete platform, with TypeScript as a de facto requirement, dependency injection, a CLI that generates everything, observables for asynchronous streams, and —recently— signals very similar to the refs you have just learned. It is the "opinionated" end of 10-01's axis: Angular Basics.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
