7.5: Composables & Lifecycle
Implement client-side routing with Vue Router to create multi-page single-page applications. Learn route configuration, navigation, dynamic routes, and how to build seamless navigation experiences.
1. Component Lifecycle
Lifecycle Overview
Vue components go through a series of lifecycle stages:
- Creation: Component instance is created
- Mounting: Component is added to DOM
- Updating: Component re-renders due to data changes
- Unmounting: Component is removed from DOM
Lifecycle Diagram
┌─────────────────┐
│ beforeCreate │ (Not available in <script setup>)
└────────┬────────┘
↓
┌─────────────────┐
│ created │ (Not available in <script setup>)
└────────┬────────┘
↓
┌─────────────────┐
│ beforeMount │
└────────┬────────┘
↓
┌─────────────────┐
│ mounted │ ← Component is in DOM, can access elements
└────────┬────────┘
↓
┌─────────────────┐
│ beforeUpdate │ ← Data changed, before DOM update
└────────┬────────┘
↓
┌─────────────────┐
│ updated │ ← DOM has been updated
└────────┬────────┘
↓
┌─────────────────┐
│ beforeUnmount │ ← Component about to be destroyed
└────────┬────────┘
↓
┌─────────────────┐
│ unmounted │ ← Component removed, cleanup done
└─────────────────┘
Basic Lifecycle Hooks
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue'
const count = ref(0)
const message = ref('Hello')
console.log('Setup runs immediately (replaces created)')
onMounted(() => {
console.log('Component mounted - DOM is ready')
// Perfect for:
// - Fetching data
// - Setting up event listeners
// - Accessing DOM elements
})
onUpdated(() => {
console.log('Component updated - DOM has changed')
// Runs after any data change causes re-render
// Be careful: can cause infinite loops if you modify data here
})
onUnmounted(() => {
console.log('Component unmounted - cleanup time')
// Perfect for:
// - Removing event listeners
// - Canceling timers
// - Closing connections
})
</script>
<template>
<div>
<p>{{ message }}</p>
<button @click="count++">Count: {{ count }}</button>
</div>
</template>
Practical Example: Data Fetching
<script setup>
import { ref, onMounted } from 'vue'
const user = ref(null)
const isLoading = ref(true)
const error = ref(null)
onMounted(async () => {
try {
isLoading.value = true
const response = await fetch('https://api.example.com/user/1')
if (!response.ok) {
throw new Error('Failed to fetch user')
}
user.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
isLoading.value = false
}
})
</script>
<template>
<div>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</div>
</template>
Cleanup Example
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const time = ref(new Date())
let intervalId = null
onMounted(() => {
// Start timer
intervalId = setInterval(() => {
time.value = new Date()
}, 1000)
})
onUnmounted(() => {
// Critical: clean up timer to prevent memory leak
if (intervalId) {
clearInterval(intervalId)
}
})
</script>
<template>
<div>
Current time: {{ time.toLocaleTimeString() }}
</div>
</template>
2. Introduction to Composables
What Are Composables?
Composables are reusable functions that encapsulate stateful logic using Vue's Composition API. They're Vue's equivalent to React hooks.
Benefits:
- Reusable logic across components
- Better code organization
- Easier testing
- Type-safe with TypeScript
- Can be shared between projects
Naming Convention
Composables use the use prefix:
useCounteruseMouseuseFetchuseLocalStorage
Basic Composable Example
// composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
function increment() {
count.value++
}
function decrement() {
count.value--
}
function reset() {
count.value = initialValue
}
return {
count,
increment,
decrement,
reset
}
}
Using the composable:
<script setup>
import { useCounter } from '@/composables/useCounter'
const { count, increment, decrement, reset } = useCounter(10)
</script>
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
<button @click="reset">Reset</button>
</div>
</template>
3. Common Composable Patterns
Mouse Position Tracker
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
Usage:
<script setup>
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<div>Mouse position: {{ x }}, {{ y }}</div>
</template>
Fetch Composable
// composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
async function fetchData() {
isLoading.value = true
error.value = null
data.value = null
try {
const urlValue = toValue(url) // Handle reactive URLs
const response = await fetch(urlValue)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
isLoading.value = false
}
}
// Re-fetch when URL changes
watchEffect(() => {
fetchData()
})
async function refetch() {
await fetchData()
}
return {
data,
error,
isLoading,
refetch
}
}
Usage:
<script setup>
import { ref } from 'vue'
import { useFetch } from '@/composables/useFetch'
const userId = ref(1)
const url = computed(() => `https://api.example.com/users/${userId.value}`)
const { data: user, error, isLoading, refetch } = useFetch(url)
</script>
<template>
<div>
<button @click="userId++">Next User</button>
<button @click="refetch">Refresh</button>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</div>
</template>
LocalStorage Composable
// composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
// Get initial value from localStorage or use default
const storedValue = localStorage.getItem(key)
const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
// Watch for changes and update localStorage
watch(
data,
(newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
},
{ deep: true }
)
return data
}
Usage:
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'
const preferences = useLocalStorage('user-preferences', {
theme: 'light',
fontSize: 16,
language: 'en'
})
</script>
<template>
<div>
<label>
Theme:
<select v-model="preferences.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
Font Size:
<input v-model.number="preferences.fontSize" type="number">
</label>
<!-- Changes automatically saved to localStorage -->
</div>
</template>
Window Size Composable
// composables/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return { width, height }
}
Usage:
<script setup>
import { computed } from 'vue'
import { useWindowSize } from '@/composables/useWindowSize'
const { width, height } = useWindowSize()
const isMobile = computed(() => width.value < 768)
const isTablet = computed(() => width.value >= 768 && width.value < 1024)
const isDesktop = computed(() => width.value >= 1024)
</script>
<template>
<div>
<p>Window: {{ width }} x {{ height }}</p>
<p v-if="isMobile">Mobile view</p>
<p v-else-if="isTablet">Tablet view</p>
<p v-else>Desktop view</p>
</div>
</template>
Debounce Composable
// composables/useDebounce.js
import { ref, watch } from 'vue'
export function useDebounce(value, delay = 500) {
const debouncedValue = ref(value.value)
let timeout = null
watch(value, (newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
Usage:
<script setup>
import { ref, watch } from 'vue'
import { useDebounce } from '@/composables/useDebounce'
const searchQuery = ref('')
const debouncedQuery = useDebounce(searchQuery, 500)
// Only runs 500ms after user stops typing
watch(debouncedQuery, (newQuery) => {
console.log('Searching for:', newQuery)
// Fetch search results
})
</script>
<template>
<input v-model="searchQuery" placeholder="Search...">
<p>Searching for: {{ debouncedQuery }}</p>
</template>
4. Advanced Watchers
watch vs watchEffect
<script setup>
import { ref, watch, watchEffect } from 'vue'
const count = ref(0)
const doubled = ref(0)
// watch - explicit dependencies
watch(count, (newCount, oldCount) => {
console.log(`Count changed from ${oldCount} to ${newCount}`)
doubled.value = newCount * 2
})
// watchEffect - automatic dependencies
watchEffect(() => {
console.log('Count is:', count.value)
doubled.value = count.value * 2
})
</script>
When to use which:
watch: When you need old and new values, or lazy watchingwatchEffect: When you want automatic dependency tracking
Deep Watching
<script setup>
import { ref, watch } from 'vue'
const user = ref({
name: 'Alice',
profile: {
age: 25,
city: 'Beijing'
}
})
// Deep watch - detects nested changes
watch(
user,
(newUser) => {
console.log('User changed:', newUser)
},
{ deep: true }
)
// Watch specific nested property
watch(
() => user.value.profile.city,
(newCity) => {
console.log('City changed to:', newCity)
}
)
</script>
Immediate Watching
<script setup>
import { ref, watch } from 'vue'
const userId = ref(1)
// Run immediately on mount
watch(
userId,
async (newId) => {
const response = await fetch(`/api/users/${newId}`)
// ...
},
{ immediate: true }
)
</script>
Conditional Watching
<script setup>
import { ref, watch } from 'vue'
const isEnabled = ref(false)
const count = ref(0)
let stopWatch = null
// Start/stop watching dynamically
watch(isEnabled, (enabled) => {
if (enabled) {
// Start watching
stopWatch = watch(count, (newCount) => {
console.log('Count:', newCount)
})
} else {
// Stop watching
if (stopWatch) {
stopWatch()
stopWatch = null
}
}
}, { immediate: true })
</script>
5. Complete Composable Example: Todo Manager
// composables/useTodoManager.js
import { ref, computed } from 'vue'
import { useLocalStorage } from './useLocalStorage'
export function useTodoManager() {
// Persist todos in localStorage
const todos = useLocalStorage('todos', [])
const filter = ref('all') // 'all', 'active', 'completed'
// Computed properties
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(t => !t.completed)
case 'completed':
return todos.value.filter(t => t.completed)
default:
return todos.value
}
})
const activeCount = computed(() => {
return todos.value.filter(t => !t.completed).length
})
const completedCount = computed(() => {
return todos.value.filter(t => t.completed).length
})
const allCompleted = computed({
get: () => todos.value.every(t => t.completed),
set: (value) => {
todos.value.forEach(t => {
t.completed = value
})
}
})
// Actions
function addTodo(text) {
todos.value.push({
id: Date.now(),
text,
completed: false,
createdAt: new Date()
})
}
function removeTodo(id) {
const index = todos.value.findIndex(t => t.id === id)
if (index !== -1) {
todos.value.splice(index, 1)
}
}
function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
function updateTodo(id, text) {
const todo = todos.value.find(t => t.id === id)
if (todo) {
todo.text = text
}
}
function clearCompleted() {
todos.value = todos.value.filter(t => !t.completed)
}
return {
// State
todos,
filter,
// Computed
filteredTodos,
activeCount,
completedCount,
allCompleted,
// Actions
addTodo,
removeTodo,
toggleTodo,
updateTodo,
clearCompleted
}
}
Using the todo manager:
<script setup>
import { ref } from 'vue'
import { useTodoManager } from '@/composables/useTodoManager'
const {
filteredTodos,
filter,
activeCount,
completedCount,
addTodo,
removeTodo,
toggleTodo,
clearCompleted
} = useTodoManager()
const newTodo = ref('')
function handleAddTodo() {
if (newTodo.value.trim()) {
addTodo(newTodo.value)
newTodo.value = ''
}
}
</script>
<template>
<div class="todo-app">
<h1>Todo List</h1>
<!-- Add todo -->
<form @submit.prevent="handleAddTodo">
<input
v-model="newTodo"
placeholder="What needs to be done?"
>
<button type="submit">Add</button>
</form>
<!-- Filter buttons -->
<div class="filters">
<button
@click="filter = 'all'"
:class="{ active: filter === 'all' }"
>
All
</button>
<button
@click="filter = 'active'"
:class="{ active: filter === 'active' }"
>
Active ({{ activeCount }})
</button>
<button
@click="filter = 'completed'"
:class="{ active: filter === 'completed' }"
>
Completed ({{ completedCount }})
</button>
</div>
<!-- Todo list -->
<ul>
<li v-for="todo in filteredTodos" :key="todo.id">
<input
type="checkbox"
:checked="todo.completed"
@change="toggleTodo(todo.id)"
>
<span :class="{ completed: todo.completed }">
{{ todo.text }}
</span>
<button @click="removeTodo(todo.id)">Delete</button>
</li>
</ul>
<!-- Clear completed -->
<button
v-if="completedCount > 0"
@click="clearCompleted"
>
Clear Completed
</button>
</div>
</template>
<style scoped>
.completed {
text-decoration: line-through;
opacity: 0.6;
}
.filters button.active {
font-weight: bold;
color: #42b983;
}
</style>
6. Practical Exercises
Exercise 5.5.1: Dark Mode Composable
Create useDarkMode composable:
- Persist preference in localStorage
- Apply class to document.body
- Provide toggle function
- Detect system preference initially
Exercise 5.5.2: Infinite Scroll Composable
Build useInfiniteScroll composable:
- Detect when user scrolls near bottom
- Trigger callback to load more data
- Handle loading state
- Clean up scroll listener
Exercise 5.5.3: Form Validation Composable
Create useFormValidation composable:
- Accept validation rules
- Track field errors
- Provide validate function
- Return isValid computed property
Exercise 5.5.4: Countdown Timer Composable
Build useCountdown composable:
- Accept target date
- Return days, hours, minutes, seconds
- Update every second
- Clean up interval on unmount
7. Knowledge Check
Question 1: What's the difference between watch and watchEffect?
Show answer
`watch` requires explicit source and gives you old/new values. `watchEffect` automatically tracks dependencies and runs immediately. Use `watch` when you need the previous value or lazy watching, `watchEffect` for simpler reactive effects.Question 2: When should you use onUnmounted?
Show answer
Use `onUnmounted` to clean up side effects: remove event listeners, clear timers/intervals, close websocket connections, cancel pending requests. This prevents memory leaks.Question 3: What makes a good composable?
Show answer
Good composables are: single-purpose, reusable across components, properly handle cleanup, return reactive values, use the `use` prefix, and are well-documented.Question 4: Why use toValue() in composables?
Show answer
`toValue()` accepts both reactive refs and regular values, making composables more flexible. It unwraps refs if present, or returns the value as-is.8. Key Takeaways
- Lifecycle hooks let you run code at specific component stages
onMountedis perfect for data fetching and DOM accessonUnmountedis critical for cleanup to prevent memory leaks- Composables encapsulate reusable stateful logic
useprefix is the naming convention for composableswatchfor explicit dependencies and old/new valueswatchEffectfor automatic dependency tracking- Deep watching detects nested object changes
- Composables can use other composables for composition
- Always clean up side effects in
onUnmounted
9. Further Resources
Official Documentation:
Composable Libraries:
- VueUse - Collection of essential Vue composables
Next Steps
Great job! You now understand Vue's lifecycle and how to create reusable composables.
In Lesson 5.6: Vue Router, you'll learn how to build multi-page single-page applications with client-side routing.