Reactive UI Fundamentals
You could build every screen in this course by hand: create a <div>,
set its textContent, listen for a click, mutate the DOM again. Frameworks exist because
that approach falls apart the moment your data changes in more than one place at once. This chapter
introduces the alternative: describing what the UI should look like for a given piece of data, and
letting a framework
keep the screen in sync for you.
The problem with manual DOM manipulation #
Say you're building a small todo list. Without a framework, every change to your data requires you to remember every place the screen depends on it:
updating the DOM by handconst todos = [{ id: 1, title: "Write chapter 6", done: false }];
function addTodo(title) {
todos.push({ id: Date.now(), title, done: false });
renderList(); // don't forget this
renderCount(); // ...or this
renderEmptyState(); // ...or this
}
function renderList() {
const ul = document.querySelector("#todo-list");
ul.innerHTML = "";
for (const todo of todos) {
const li = document.createElement("li");
li.textContent = todo.title;
ul.appendChild(li);
}
}
Every function that reads todos has to be called, in the right order, every time
todos changes. Miss one call and the screen silently drifts out of sync with your data.
This is not a hypothetical — it's the single most common source of UI bugs in hand-rolled
DOM
code. A component-based, reactive framework exists to remove this bookkeeping entirely.
A component is a self-contained piece of UI: a template that describes structure, some data that drives it, and logic that updates the data. You stop thinking "which functions do I need to re-run" and start thinking "what does the screen look like given this data" — the framework figures out the rest.
Declarative templates #
The syntax below matches Vue 3's Composition API, which is what the rest of this course uses directly — see the official Vue introduction for the canonical reference. A template is HTML with a few extra pieces of syntax layered on top:
{{ expression }}— interpolation. Drops the value of a JavaScript expression into the text of the page.v-if/v-else— conditionally render an element.v-for— repeat an element once per item in a list.@click(short forv-on:click) — bind an event handler.
<template>
<p v-if="todos.length === 0">Nothing to do yet.</p>
<ul v-else>
<li v-for="todo in todos" :key="todo.id">
<span>{{ todo.title }}</span>
<button @click="markDone(todo.id)">Done</button>
</li>
</ul>
<p>{{ todos.length }} total</p>
</template>
Notice what's missing: there is no renderList(), no manual loop building
<li> elements, no code that decides whether to show the empty-state paragraph. You
wrote down what the UI is for a given todos array. The framework's job is to make
that description true on screen, and to keep it true as todos changes.
Reactivity: ref and computed #
For the template above to update automatically, todos can't be a plain array — the framework needs to know when it changes. That's what
reactive
values are for. The two you'll use constantly are ref() and computed(),
covered in depth in Vue's
reactivity fundamentals guide.
Both work the same way whether you call them from plain JavaScript or, as you'll see below, from
inside a component's <script setup> block — the snippet below is shown on its
own first so you can focus on what ref and computed actually do, before
seeing where they live inside a real component.
import { ref, computed } from "vue";
const todos = ref([
{ id: 1, title: "Write chapter 6", done: false },
]);
const remaining = computed(() =>
todos.value.filter((t) => !t.done).length
);
function markDone(id) {
const todo = todos.value.find((t) => t.id === id);
if (todo) todo.done = true;
}
ref() wraps a value so the framework can track reads and writes to it (you access or
change the underlying value through .value in plain JavaScript — the template
unwraps this automatically, which is why the earlier example writes todos instead of
todos.value). computed() derives a new reactive value from other reactive
values; it recalculates only when one of its dependencies changes, and anything displaying
remaining updates the instant markDone() runs. You never call a
"re-render" function yourself.
From data change to pixels: the virtual DOM #
When a reactive value changes, the framework doesn't tear down and rebuild the real DOM from scratch — that would be slow and would lose things like input focus and scroll position. Instead it builds a lightweight in-memory description of what the DOM should look like (the "virtual DOM"), compares it to the previous description, and applies only the differences to the real DOM. This comparison-and-patch step is what people mean when they say a framework "re-renders" a component.
You rarely think about this step directly. The practical takeaway is: mutate the reactive value, and trust the framework to update the minimal set of real DOM nodes.
Single-file components #
A component's template, logic, and styling live together in one .vue file. The
<script setup> block is where you declare refs, computed values, and functions;
everything you declare there is automatically available to the template below it — no manual
exporting or binding required.
<script setup>
import { ref, computed } from "vue";
const todos = ref([
{ id: 1, title: "Write chapter 6", done: false },
]);
const remaining = computed(() =>
todos.value.filter((t) => !t.done).length
);
function markDone(id) {
const todo = todos.value.find((t) => t.id === id);
if (todo) todo.done = true;
}
</script>
<template>
<ul>
<li v-for="todo in todos" :key="todo.id">
<span :class="{ done: todo.done }">{{ todo.title }}</span>
<button @click="markDone(todo.id)">Done</button>
</li>
</ul>
<p>{{ remaining }} remaining</p>
</template>
<style scoped>
.done {
text-decoration: line-through;
color: var(--fg-3);
}
</style>
The <style scoped> block's rules only apply to this component's own markup — you'll see exactly how that isolation works in
Chapter 14. For now, the important shape to remember is
three sections, one file: script for behavior, template for structure, style for appearance.
If a template feels like it's fighting you — too many v-if chains, logic
leaking into markup — that's usually a sign the logic belongs in <script setup>
as a computed value or a small function, not in the template itself. Templates describe; scripts
decide.
In the TodoList.vue example above, add a second computed value,
allDone, that's true when every todo has done: true. Then add
a <p v-if="allDone"> congratulating the user. You won't need to touch
markDone() at all — that's the point.