Master Vue reactivity — ref for state, mutation-friendly updates, computed values, watchers, template refs, lifecycle hooks, and reusable composables.
Why: props come from the parent and are read-only; state is owned by the component and changes over time. You write state as const count = ref(initialValue) — read and write count.value in the script, and just count in the template (Vue unwraps it for you). Assigning to it updates the screen — that is the entire Vue update model. Note: in a Nuxt app, ref, computed, and friends are auto-imported, so the import line is optional there.
<script setup lang="ts">
import { ref } from 'vue'
// step is a prop (passed in, read-only)
const { step } = defineProps<{ step: number }>()
// count is state (owned here, changes over time)
const count = ref(0)
</script>
<template>
<!-- In the template you drop .value — Vue unwraps refs automatically -->
<button @click="count += step">Count: {{ count }}</button>
</template>Why: Vue wraps your state in a proxy — a thin layer that notices every change. So unlike React, mutating IS allowed: push into arrays, change fields on objects, and the screen updates. No spreading, no setters, no immutability rules.
<script setup lang="ts">
import { ref } from 'vue'
const todos = ref<string[]>([])
const user = ref({ name: 'Ada', age: 36 })
function addTodo() {
// Mutating is fine — Vue tracks changes deeply (unlike React)
todos.value.push('Todo #' + (todos.value.length + 1))
}
function birthday() {
user.value.age++
}
</script>
<template>
<div>
<button @click="addTodo">Add</button>
<button @click="birthday">{{ user.name }} is {{ user.age }}</button>
<p>{{ todos.join(', ') }}</p>
</div>
</template>Why: a computed is a value calculated from other state — it recalculates only when something it reads changes, and caches the result in between. Reach for it whenever one value can be derived from another instead of storing both. Note: v-model keeps the input and the ref in sync — more in the Forms lesson.
<script setup lang="ts">
import { computed, ref } from 'vue'
const query = ref('')
const products = ref(['Laptop', 'Phone', 'Tablet'])
// Recalculates only when query or products change — and caches the result
const filtered = computed(() =>
products.value.filter((p) =>
p.toLowerCase().includes(query.value.toLowerCase()),
),
)
</script>
<template>
<div>
<input v-model="query" placeholder="Search…" />
<p>{{ filtered.length }} result(s): {{ filtered.join(', ') }}</p>
</div>
</template>Why: watchers run side effects when state changes — call an API after the user types, save to localStorage, log. watch(source, callback) is explicit about what it reacts to; watchEffect(fn) runs immediately and re-runs whenever anything it read changes. When: reach for computed first; watch is for effects, not for deriving values.
<script setup lang="ts">
import { ref, watch } from 'vue'
const query = ref('')
const results = ref<string[]>([])
// Runs the callback whenever query changes
watch(query, async (newQuery) => {
if (!newQuery) {
results.value = []
return
}
const res = await fetch('/api/search?q=' + newQuery)
results.value = await res.json()
})
</script>
<template>
<div>
<input v-model="query" placeholder="Search…" />
<p>{{ results.length }} results</p>
</div>
</template>Why: sometimes you need the real element on the page — to focus an input, scroll, or measure it. Put ref="name" on the element and read it with useTemplateRef. Note: the element only exists after the component is on screen, so it is null at first — hence the ?. when using it.
<script setup lang="ts">
import { useTemplateRef } from 'vue'
// The string must match the ref="…" attribute below
const inputEl = useTemplateRef('search')
function focusInput() {
inputEl.value?.focus()
}
</script>
<template>
<div>
<input ref="search" placeholder="Search…" />
<button @click="focusInput">Focus input</button>
</div>
</template>Why: lifecycle hooks connect your component to things Vue does not control — timers, subscriptions, browser APIs. onMounted runs once the component is on screen ("mounted"); onUnmounted runs when it leaves — undo there whatever you started.
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
// null until the first tick — that's why the type is Date | null
const now = ref<Date | null>(null)
let id: ReturnType<typeof setInterval>
// Runs once the component is on screen — start timers and listeners here
onMounted(() => {
id = setInterval(() => (now.value = new Date()), 1000)
})
// Runs when the component leaves the screen — clean up what you started
onUnmounted(() => clearInterval(id))
</script>
<template>
<p>{{ now ? now.toLocaleTimeString() : 'Loading…' }}</p>
</template>Why: any reusable logic built from refs, computed, and watchers can be extracted into a function whose name starts with "use" — that is a composable, Vue's answer to React custom hooks. Note: in Nuxt, files in app/composables/ are auto-imported; the VueUse library is a huge collection of ready-made ones.
// app/composables/useDebounce.ts — Nuxt auto-imports anything here
import { ref, watch, type Ref } from 'vue'
// Reusable logic = a function that starts with "use" and returns refs
// (the <T> just means it works for any type of value)
export function useDebounce<T>(source: Ref<T>, ms = 300) {
const debounced = ref(source.value) as Ref<T>
let id: ReturnType<typeof setTimeout>
watch(source, (value) => {
// Wait ms, then accept the value — cancelled if it changes sooner
clearTimeout(id)
id = setTimeout(() => (debounced.value = value), ms)
})
return debounced
}
// In any component:
// const text = ref('')
// const query = useDebounce(text, 500) // settles 500ms after typing stops