Progressive Vue

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

  1. v-model="searchQuery" — Input and data stay in sync
  2. @click="toggleTag(tag)" — Clicking a tag toggles the filter
  3. computed(() => ...) — Books are automatically filtered by query and tag
  4. v-if="filteredBooks.length === 0" — Shows a message when no results
  5. :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 attributes
  • v-model synchronizes input and data bidirectionally
  • ref() creates reactive data
  • computed() auto-calculates values derived from data
  • Define everything in setup() and return it — no this needed
  • 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.html file

Related

Back to book