Skip to content

Composables

Composables are reusable functions that encapsulate stateful logic using Vue's Composition API. In this tutorial, you'll learn how to create and use composables to share logic across components.

What are Composables?

Composables are functions that leverage Vue's reactivity system to create reusable, stateful logic.

┌─────────────────────────────────────────────────────────────┐
│                    Composables                               │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   Traditional Approach         With Composables             │
│   ┌─────────────────────┐     ┌─────────────────────┐      │
│   │ Component A         │     │ useCounter()        │      │
│   │ - counter logic     │     │ - count             │      │
│   │ - mouse logic       │     │ - increment         │      │
│   └─────────────────────┘     │ - decrement         │      │
│   ┌─────────────────────┐     └─────────────────────┘      │
│   │ Component B         │              ↓                   │
│   │ - same counter      │     Used by multiple             │
│   │ - same mouse        │     components!                  │
│   └─────────────────────┘                                  │
│                                                             │
│   Problem: Duplicate code     Solution: Shared logic        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Creating Composables

Basic Composable

js
// src/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
  }
}
ts
// src/composables/useCounter.ts
import { ref, Ref } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  increment: () => void
  decrement: () => void
  reset: () => void
}

export function useCounter(initialValue: number = 0): UseCounterReturn {
  const count: Ref<number> = ref(initialValue)

  function increment(): void {
    count.value++
  }

  function decrement(): void {
    count.value--
  }

  function reset(): void {
    count.value = initialValue
  }

  return {
    count,
    increment,
    decrement,
    reset
  }
}

Using the Composable

vue
<script setup>
import { useCounter } from '@/composables/useCounter'

// Each component gets its own state
const { count, increment, decrement, reset } = useCounter(10)

// You can use multiple counters
const counter2 = useCounter(0)
</script>

<template>
  <div>
    <h2>Counter 1: {{ count }}</h2>
    <button @click="decrement">-</button>
    <button @click="increment">+</button>
    <button @click="reset">Reset</button>

    <h2>Counter 2: {{ counter2.count }}</h2>
    <button @click="counter2.increment">+</button>
  </div>
</template>
vue
<script setup lang="ts">
import { useCounter } from '@/composables/useCounter'

// Each component gets its own state
const { count, increment, decrement, reset } = useCounter(10)

// You can use multiple counters
const counter2 = useCounter(0)
</script>

<template>
  <div>
    <h2>Counter 1: {{ count }}</h2>
    <button @click="decrement">-</button>
    <button @click="increment">+</button>
    <button @click="reset">Reset</button>

    <h2>Counter 2: {{ counter2.count }}</h2>
    <button @click="counter2.increment">+</button>
  </div>
</template>

Common Composable Patterns

useFetch - Data Fetching

js
// src/composables/useFetch.js
import { ref, watchEffect, toValue } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)

  async function fetchData() {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  // Fetch immediately and refetch when url changes
  watchEffect(() => {
    fetchData()
  })

  return {
    data,
    error,
    loading,
    refetch: fetchData
  }
}
ts
// src/composables/useFetch.ts
import { ref, watchEffect, toValue, Ref, MaybeRefOrGetter } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  loading: Ref<boolean>
  refetch: () => Promise<void>
}

export function useFetch<T>(url: MaybeRefOrGetter<string>): UseFetchReturn<T> {
  const data: Ref<T | null> = ref(null)
  const error: Ref<string | null> = ref(null)
  const loading: Ref<boolean> = ref(false)

  async function fetchData(): Promise<void> {
    loading.value = true
    error.value = null

    try {
      const response = await fetch(toValue(url))
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      data.value = await response.json()
    } catch (err) {
      error.value = (err as Error).message
    } finally {
      loading.value = false
    }
  }

  // Fetch immediately and refetch when url changes
  watchEffect(() => {
    fetchData()
  })

  return {
    data,
    error,
    loading,
    refetch: fetchData
  }
}

Usage:

vue
<script setup>
import { ref, computed } from 'vue'
import { useFetch } from '@/composables/useFetch'

const userId = ref(1)
const url = computed(() => `https://jsonplaceholder.typicode.com/users/${userId.value}`)

const { data: user, error, loading, refetch } = useFetch(url)
</script>

<template>
  <div>
    <select v-model="userId">
      <option v-for="id in 10" :key="id" :value="id">User {{ id }}</option>
    </select>

    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else-if="user">
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>

    <button @click="refetch">Refetch</button>
  </div>
</template>
vue
<script setup lang="ts">
import { ref, computed, Ref, ComputedRef } from 'vue'
import { useFetch } from '@/composables/useFetch'

interface User {
  id: number
  name: string
  email: string
}

const userId: Ref<number> = ref(1)
const url: ComputedRef<string> = computed(() =>
  `https://jsonplaceholder.typicode.com/users/${userId.value}`
)

const { data: user, error, loading, refetch } = useFetch<User>(url)
</script>

<template>
  <div>
    <select v-model="userId">
      <option v-for="id in 10" :key="id" :value="id">User {{ id }}</option>
    </select>

    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else-if="user">
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>

    <button @click="refetch">Refetch</button>
  </div>
</template>

useMouse - Mouse Position

js
// src/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.clientX
    y.value = event.clientY
  }

  onMounted(() => {
    window.addEventListener('mousemove', update)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })

  return { x, y }
}
ts
// src/composables/useMouse.ts
import { ref, onMounted, onUnmounted, Ref } from 'vue'

interface UseMouseReturn {
  x: Ref<number>
  y: Ref<number>
}

export function useMouse(): UseMouseReturn {
  const x: Ref<number> = ref(0)
  const y: Ref<number> = ref(0)

  function update(event: MouseEvent): void {
    x.value = event.clientX
    y.value = event.clientY
  }

  onMounted(() => {
    window.addEventListener('mousemove', update)
  })

  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })

  return { x, y }
}

useLocalStorage - Persistent State

js
// src/composables/useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  // Get initial value from localStorage
  const stored = localStorage.getItem(key)
  const initial = stored ? JSON.parse(stored) : defaultValue

  const data = ref(initial)

  // Watch for changes and sync to localStorage
  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  return data
}
ts
// src/composables/useLocalStorage.ts
import { ref, watch, Ref } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
  // Get initial value from localStorage
  const stored = localStorage.getItem(key)
  const initial: T = stored ? JSON.parse(stored) : defaultValue

  const data: Ref<T> = ref(initial) as Ref<T>

  // Watch for changes and sync to localStorage
  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  return data
}

Usage:

vue
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage'

// This persists to localStorage automatically!
const theme = useLocalStorage('theme', 'light')
const settings = useLocalStorage('settings', {
  notifications: true,
  language: 'en'
})
</script>

<template>
  <div>
    <select v-model="theme">
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>

    <label>
      <input type="checkbox" v-model="settings.notifications" />
      Enable notifications
    </label>
  </div>
</template>

useDebounce - Debounced Value

js
// src/composables/useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timeout = null

  watch(value, (newValue) => {
    if (timeout) clearTimeout(timeout)
    timeout = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}
ts
// src/composables/useDebounce.ts
import { ref, watch, Ref } from 'vue'

export function useDebounce<T>(value: Ref<T>, delay: number = 300): Ref<T> {
  const debouncedValue: Ref<T> = ref(value.value) as Ref<T>
  let timeout: ReturnType<typeof setTimeout> | null = null

  watch(value, (newValue: T) => {
    if (timeout) clearTimeout(timeout)
    timeout = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}

Composable Best Practices

Naming Convention

Always use use prefix:

js
// ✅ Good
useCounter()
useFetch()
useAuth()

// ❌ Bad
counter()
fetchData()
auth()

Return Reactive References

js
// ✅ Good - return refs (keeps reactivity)
export function useCounter() {
  const count = ref(0)
  return { count }
}

// ❌ Bad - returning .value loses reactivity
export function useCounter() {
  const count = ref(0)
  return { count: count.value }
}
ts
// ✅ Good - return refs (keeps reactivity)
export function useCounter(): { count: Ref<number> } {
  const count: Ref<number> = ref(0)
  return { count }
}

// ❌ Bad - returning .value loses reactivity
export function useCounter(): { count: number } {
  const count: Ref<number> = ref(0)
  return { count: count.value }  // Not reactive!
}

Clean Up Side Effects

Always clean up in onUnmounted:

js
export function useEventListener(target, event, callback) {
  onMounted(() => {
    target.addEventListener(event, callback)
  })

  // ✅ Always clean up!
  onUnmounted(() => {
    target.removeEventListener(event, callback)
  })
}

Summary

ConceptDescription
ComposableReusable function with Vue reactivity
NamingAlways prefix with use
StateReturn refs to keep reactivity
CleanupUse onUnmounted for side effects
SharingImport and call in any component

What's Next?

In the next chapter, we'll learn about Vue Router - navigation in single-page applications.


Previous: Lifecycle Hooks | Next: Vue Router →