Hack Frontend Community

Composables in Vue.js

What are Composables?

Composables are functions that use Composition API to encapsulate and reuse stateful logic between components.

Composables in Vue are similar to custom hooks in React.


Basic Example

// 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
  }
}

Usage:

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

<script setup>
import { useCounter } from './useCounter'

const { count, increment, decrement, reset } = useCounter(10)
</script>

Naming Conventions

Composables are usually named with use prefix:

// Good
useCounter()
useMouse()
useFetch()
useLocalStorage()

// Bad
counter()
mouse()
fetch()

useMouse

Track mouse position:

// 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 }
}

useFetch

Fetch data from API:

// useFetch.js
import { ref } from 'vue'

export function useFetch(url) {
  const data = ref(null)
  const error = ref(null)
  const loading = ref(false)
  
  async function fetch() {
    loading.value = true
    error.value = null
    
    try {
      const response = await window.fetch(url)
      data.value = await response.json()
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
  
  fetch()
  
  return { data, error, loading, refetch: fetch }
}

useLocalStorage

Sync with localStorage:

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

export function useLocalStorage(key, defaultValue) {
  const value = ref(defaultValue)
  
  // Read from localStorage on init
  const stored = localStorage.getItem(key)
  if (stored) {
    try {
      value.value = JSON.parse(stored)
    } catch (e) {
      console.error('Error parsing localStorage value:', e)
    }
  }
  
  // Save on change
  watch(value, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })
  
  return value
}

Composing Composables

Composables can be combined:

// useUser.js
import { useFetch } from './useFetch'
import { useLocalStorage } from './useLocalStorage'

export function useUser() {
  const userId = useLocalStorage('userId', null)
  
  const url = computed(() => 
    userId.value ? `/api/users/${userId.value}` : null
  )
  
  const { data: user, loading, error } = useFetch(url)
  
  function login(id) {
    userId.value = id
  }
  
  function logout() {
    userId.value = null
  }
  
  return {
    user,
    loading,
    error,
    login,
    logout
  }
}

Best Practices

Return refs, not reactive

// Good
export function useCounter() {
  const count = ref(0)
  return { count }
}

// Bad
export function useCounter() {
  const state = reactive({ count: 0 })
  return state
}

Reason: refs preserve reactivity when destructured.

Cleanup in onUnmounted

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

Use toValue for flexibility

import { toValue } from 'vue'

export function useFetch(url) {
  // url can be ref, computed or plain value
  const actualUrl = toValue(url)
  // ...
}

Composables vs Mixins

Mixins (Vue 2)

// Bad - mixins
const counterMixin = {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}

Mixins problems:

  • Unclear property source
  • Name conflicts
  • Implicit dependencies

Composables (Vue 3)

// Good - composables
export function useCounter() {
  const count = ref(0)
  
  function increment() {
    count.value++
  }
  
  return { count, increment }
}

Composables advantages:

  • Explicit source
  • No name conflicts
  • Better typing
  • Easier to test

Common Mistakes

Calling composable outside setup

// Wrong
export default {
  data() {
    const { x, y } = useMouse() // Error!
    return { x, y }
  }
}

// Correct
export default {
  setup() {
    const { x, y } = useMouse()
    return { x, y }
  }
}

Conditional composable call

// Wrong
if (condition) {
  const { count } = useCounter() // Error!
}

// Correct
const { count } = useCounter()
if (condition) {
  // use count
}

Forgetting cleanup

// Wrong - memory leak
export function useMouse() {
  const x = ref(0)
  
  function update(event) {
    x.value = event.pageX
  }
  
  window.addEventListener('mousemove', update)
  // Forgot removeEventListener!
  
  return { x }
}

// Correct
export function useMouse() {
  const x = ref(0)
  
  function update(event) {
    x.value = event.pageX
  }
  
  onMounted(() => {
    window.addEventListener('mousemove', update)
  })
  
  onUnmounted(() => {
    window.removeEventListener('mousemove', update)
  })
  
  return { x }
}

Conclusion

Composables:

  • Reusable logic with Composition API
  • Better than mixins
  • Named with use prefix
  • Return refs
  • Require cleanup in onUnmounted
  • Can be combined
  • Support TypeScript

In interviews:

Important to be able to:

  • Explain what composables are and why they're needed
  • Show examples of creating composables
  • Describe advantages over mixins
  • Explain best practices (naming, cleanup, returning refs)
  • Give examples of popular composables