Reactivity — From Static to Dynamic
From Static to Dynamic
So far, we can drive HTML templates with data.
But it's not "dynamic" yet — it only shows data at the moment the page loads.
In this chapter, we introduce a mechanism where the screen updates in real-time in response to user actions.
This is Vue's reactivity system.
v-on: Event Handling
First, we use the v-on directive to capture user interactions.
<button v-on:click="count++">
Click count: {{ count }}
</button>
Shorthand — use @.
<button @click="count++">
Click count: {{ count }}
</button>
@click — this is also an HTML attribute. Written in the same position as onclick.
<div id="app">
<button @click="count++">
Click count: {{ count }}
</button>
</div>
<script type="importmap">
{ "imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" } }
</script>
<script type="module">
import { createApp, ref } from 'vue'
createApp({
setup() {
const count = ref(0)
return { count }
}
}).mount('#app')
</script>
Each click automatically updates the display.
No manual DOM manipulation needed. No document.getElementById, no innerHTML.
Change the data, the screen changes.
This is reactivity.
Data created with ref() is automatically tracked by Vue — when the value changes, the screen re-renders.
Inside templates, you use it directly without .value (in setup() it's count.value, but in the template it's just count).
v-model: Two-Way Binding
Let's add search functionality using v-model.
<input v-model="searchQuery" placeholder="Search..." />
v-model synchronizes the <input> value and reactive data bidirectionally.
Type something, and searchQuery updates. Change searchQuery, and the input updates.
Again, just one attribute on the <input> tag.
computed(): Computed Properties
To filter books based on the search query, use computed().
import { createApp, ref, computed } from 'vue'
createApp({
setup() {
const searchQuery = ref('')
const books = ref([ /* ... */ ])
const filteredBooks = computed(() => {
if (!searchQuery.value) return books.value
const q = searchQuery.value.toLowerCase()
return books.value.filter(book =>
book.title.toLowerCase().includes(q)
)
})
return { searchQuery, books, filteredBooks }
}
}).mount('#app')
computed() defines values derived from data.
When searchQuery or books changes, filteredBooks recalculates automatically.
In the template, just use filteredBooks instead of books.
<div class="card" v-for="book in filteredBooks">
Making the Bookshelf App Dynamic
Here's the complete code combining everything.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My App</title>
<style>
body {
font-family: sans-serif;
max-width: 600px;
margin: 40px auto;
padding: 0 20px;
}
.search {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1em;
margin-bottom: 16px;
box-sizing: border-box;
}
.card {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
.card h2 { margin-top: 0; }
.tag {
display: inline-block;
background: #edf2f7;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.85em;
margin-right: 4px;
cursor: pointer;
}
.tag:hover { background: #e2e8f0; }
.tag.active { background: #4299e1; color: white; }
.filters { margin-bottom: 16px; }
.no-results {
text-align: center;
color: #a0aec0;
padding: 40px 0;
}
</style>
</head>
<body>
<div id="app">
<h1>My Bookshelf</h1>
<input
class="search"
v-model="searchQuery"
placeholder="Search by title..."
/>
<div class="filters">
<span
class="tag"
:class="{ active: selectedTag === tag }"
v-for="tag in allTags"
@click="toggleTag(tag)"
>
{{ tag }}
</span>
</div>
<div v-if="filteredBooks.length === 0" class="no-results">
No books found
</div>
<div class="card" v-for="book in filteredBooks">
<h2>{{ book.title }}</h2>
<p>{{ book.description }}</p>
<span class="tag" v-for="tag in book.tags">
{{ tag }}
</span>
</div>
</div>
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<script type="module">
import { createApp, ref, computed } from 'vue'
createApp({
setup() {
const searchQuery = ref('')
const selectedTag = ref(null)
const books = ref([
{
title: 'Readable Code',
description: 'Practical techniques for writing readable code.',
tags: ['programming', 'practices']
},
{
title: 'Fundamentals of Programming Languages',
description: 'Learn the fundamental concepts behind language design.',
tags: ['cs', 'language']
},
{
title: 'Web Browser Security',
description: 'A systematic guide to browser security.',
tags: ['security', 'browser']
}
])
const allTags = computed(() => {
const tags = new Set()
for (const book of books.value) {
for (const tag of book.tags) {
tags.add(tag)
}
}
return [...tags]
})
const filteredBooks = computed(() => {
return books.value.filter(book => {
const matchesSearch = !searchQuery.value ||
book.title.toLowerCase().includes(searchQuery.value.toLowerCase())
const matchesTag = !selectedTag.value ||
book.tags.includes(selectedTag.value)
return matchesSearch && matchesTag
})
})
const toggleTag = (tag) => {
selectedTag.value = selectedTag.value === tag ? null : tag
}
return { searchQuery, selectedTag, books, allTags, filteredBooks, toggleTag }
}
}).mount('#app')
</script>
</body>
</html>
What's Happening
v-model="searchQuery"— Input and data stay in sync@click="toggleTag(tag)"— Clicking a tag toggles the filtercomputed(() => ...)— Books are automatically filtered by query and tagv-if="filteredBooks.length === 0"— Shows a message when no results:class="{ active: selectedTag === tag }"— Applies style to the selected tag
There's not a single line of direct DOM manipulation.
Just change the data, and Vue updates the screen.
Looking Back at the Template
Let's look at just the template portion.
<h1>My Bookshelf</h1>
<input class="search" v-model="searchQuery" placeholder="Search by title..." />
<div class="filters">
<span class="tag" :class="{ active: selectedTag === tag }" v-for="tag in allTags" @click="toggleTag(tag)">
{{ tag }}
</span>
</div>
<div v-if="filteredBooks.length === 0" class="no-results">
No books found
</div>
<div class="card" v-for="book in filteredBooks">
<h2>{{ book.title }}</h2>
<p>{{ book.description }}</p>
<span class="tag" v-for="tag in book.tags">{{ tag }}</span>
</div>
This is HTML.
v-model, v-for, v-if, @click, :class — they blend naturally as HTML attributes.
You need almost no JavaScript knowledge to read the template structure.
Looking Back at setup()
Let's also review the JavaScript side.
setup() {
const searchQuery = ref('')
const selectedTag = ref(null)
const books = ref([ /* ... */ ])
const allTags = computed(() => { /* ... */ })
const filteredBooks = computed(() => { /* ... */ })
const toggleTag = (tag) => { /* ... */ }
return { searchQuery, selectedTag, books, allTags, filteredBooks, toggleTag }
}
setup() is just a function.
Create reactive data with ref(), define derived values with computed(), write logic as plain functions.
No this. Everything is explicit variables and functions.
Summary
v-on(@) handles events — written as HTML attributesv-modelsynchronizes input and data bidirectionallyref()creates reactive datacomputed()auto-calculates values derived from data- Define everything in
setup()andreturnit — nothisneeded - Change the data, the screen changes — this is reactivity
- No direct DOM manipulation code needed
- Templates remain HTML even when dynamic
- Still just a single
index.htmlfile