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:

  1. Creation: Component instance is created
  2. Mounting: Component is added to DOM
  3. Updating: Component re-renders due to data changes
  4. 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:

  • useCounter
  • useMouse
  • useFetch
  • useLocalStorage

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 watching
  • watchEffect: 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:

  1. Persist preference in localStorage
  2. Apply class to document.body
  3. Provide toggle function
  4. Detect system preference initially

Exercise 5.5.2: Infinite Scroll Composable

Build useInfiniteScroll composable:

  1. Detect when user scrolls near bottom
  2. Trigger callback to load more data
  3. Handle loading state
  4. Clean up scroll listener

Exercise 5.5.3: Form Validation Composable

Create useFormValidation composable:

  1. Accept validation rules
  2. Track field errors
  3. Provide validate function
  4. Return isValid computed property

Exercise 5.5.4: Countdown Timer Composable

Build useCountdown composable:

  1. Accept target date
  2. Return days, hours, minutes, seconds
  3. Update every second
  4. 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
  • onMounted is perfect for data fetching and DOM access
  • onUnmounted is critical for cleanup to prevent memory leaks
  • Composables encapsulate reusable stateful logic
  • use prefix is the naming convention for composables
  • watch for explicit dependencies and old/new values
  • watchEffect for 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.