Vue.js Composables
Introduction
Composables represent one of the most powerful features of Vue's Composition API. In essence, composables are reusable JavaScript functions that encapsulate and reuse stateful logic between Vue components.
Unlike mixins or higher-order components from Vue 2, composables offer a more transparent way to share code with better TypeScript support and fewer naming collisions. They form the foundation of how code organization and reuse works in the Vue 3 ecosystem.
In this guide, you'll learn:
- What composables are and why they're valuable
- How to create your own composables
- Best practices for writing effective composables
- How to use built-in composables from the Vue ecosystem
Understanding Composables
What Are Composables?
A composable is a function that leverages Vue's Composition API to encapsulate and reuse stateful logic. The name comes from their ability to be composed together like building blocks to create complex reactive behavior.
Composables typically:
- Start with "use" (by convention)
- Use reactive Vue features like
ref
,computed
,watch
- Return an object containing reactive state and functions
Why Use Composables?
Composables solve several problems:
- Code Reuse: Extract common logic that can be shared across components
- Better Organization: Group related code together functionally rather than by lifecycle
- Type Safety: Offer better TypeScript integration than options API alternatives
- Testing: Isolated functions are easier to test than component options
- Readability: Make the source of reactive variables more transparent
Creating Your First Composable
Let's start with a simple example: a counter composable.
// 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 in a component:
<template>
<div>
<p>Count: {{ count }}</p>
<button @click="increment">Increment</button>
<button @click="decrement">Decrement</button>
<button @click="reset">Reset</button>
</div>
</template>
<script setup>
import { useCounter } from './useCounter'
const { count, increment, decrement, reset } = useCounter(10)
</script>
What's Happening Here?
- We create a function called
useCounter
that accepts an optional initial value - Inside, we define a reactive
count
state usingref
- We define methods that manipulate that state
- We return an object containing both the reactive state and the methods
- In our component, we destructure the returned values and use them directly
This pattern creates a clean separation between the component's UI concerns and the logic for managing the counter.
Creating More Complex Composables
Let's look at a more practical example: a composable for handling asynchronous data fetching.
// useFetch.js
import { ref, computed, watchEffect } from 'vue'
export function useFetch(getUrl) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
const fetchData = async () => {
isLoading.value = true
error.value = null
try {
const url = typeof getUrl === 'function' ? getUrl() : getUrl
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Network error: ${response.status}`)
}
data.value = await response.json()
} catch (err) {
error.value = err.message || 'An error occurred'
} finally {
isLoading.value = false
}
}
const hasData = computed(() => !!data.value)
// If getUrl is reactive, re-run the effect when it changes
watchEffect(() => {
if (getUrl) fetchData()
})
return {
data,
error,
isLoading,
hasData,
fetchData
}
}
Using our fetch composable:
<template>
<div>
<h1>User Profile</h1>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else-if="hasData">
<h2>{{ data.name }}</h2>
<p>Email: {{ data.email }}</p>
<button @click="fetchData">Refresh</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useFetch } from './useFetch'
const userId = ref(1)
const url = computed(() => `https://jsonplaceholder.typicode.com/users/${userId.value}`)
const { data, error, isLoading, hasData, fetchData } = useFetch(url)
</script>
This composable abstracts away all the complexity of handling loading states, errors, and data fetching. The component simply receives the current state and can focus on rendering.
Composing Composables Together
One of the most powerful aspects of composables is that they can use other composables. Let's see how we can combine multiple composables:
// useUserProfile.js
import { ref, computed } from 'vue'
import { useFetch } from './useFetch'
import { useLocalStorage } from './useLocalStorage'
export function useUserProfile(userId) {
// Use the storage composable to remember the last viewed profile
const { value: lastViewedId, setValue: setLastViewedId } = useLocalStorage(
'last-viewed-user-id',
userId
)
// Create a URL based on the user ID
const profileUrl = computed(() =>
`https://api.example.com/users/${userId.value}`
)
// Use our fetch composable to load the data
const { data: user, isLoading, error, fetchData } = useFetch(profileUrl)
// Save the last viewed ID when data is loaded
const loadUser = async (id) => {
userId.value = id
await fetchData()
setLastViewedId(id)
}
// Computed properties from user data
const fullName = computed(() => {
if (!user.value) return ''
return `${user.value.firstName} ${user.value.lastName}`
})
return {
user,
isLoading,
error,
loadUser,
fullName,
lastViewedId
}
}
In this example, useUserProfile
combines two other composables - useFetch
and useLocalStorage
- creating higher-level functionality from these building blocks.
Common Built-in Vue Composables
Vue itself provides several built-in composables you can use:
useAttrs
Gives access to all attributes passed to a component that aren't declared as props.
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.class) // Access non-prop attributes
useSlots
Provides access to the slots passed to the component.
import { useSlots } from 'vue'
const slots = useSlots()
const hasDefaultSlot = !!slots.default
useVModel
Creates a two-way binding for props (new in Vue 3.4).
import { useVModel } from 'vue'
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const value = useVModel(props, 'modelValue', emit)
Best Practices for Creating Composables
To make the most of composables, follow these best practices:
1. Name Composables with "use" Prefix
This helps distinguish composable functions from regular utility functions:
// Good
export function useWindowSize() { /* ... */ }
// Avoid
export function windowSize() { /* ... */ }
2. Return Plain Objects
Always return a plain object with named values instead of arrays:
// Good - destructuring is clear
const { count, increment } = useCounter()
// Avoid - order dependency is fragile
const [count, increment] = useCounter()
3. Accept Configuration Options
Make composables flexible by accepting configuration:
export function useFetch(url, options = {}) {
const { immediate = true, headers = {} } = options
// ...
}
4. Document Return Values
Use JSDoc comments to document what your composable returns:
/**
* Manages pagination state
* @returns {{
* currentPage: Ref<number>,
* pageSize: Ref<number>,
* totalItems: Ref<number>,
* totalPages: ComputedRef<number>,
* nextPage: () => void,
* prevPage: () => void,
* goToPage: (page: number) => void
* }}
*/
export function usePagination(initialPage = 1, initialPageSize = 10) {
// Implementation...
}
5. Clean Up Resources
If your composable creates side effects (like event listeners), clean them up:
import { onUnmounted } from 'vue'
export function useEventListener(target, event, handler) {
target.addEventListener(event, handler)
onUnmounted(() => {
target.removeEventListener(event, handler)
})
}
Practical Examples
Let's look at some real-world examples of useful composables:
Mouse Position Tracker
// useMousePosition.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMousePosition() {
const x = ref(0)
const y = ref(0)
function update(e) {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => {
window.addEventListener('mousemove', update)
})
onUnmounted(() => {
window.removeEventListener('mousemove', update)
})
return { x, y }
}
Form Validation
// useFormValidation.js
import { reactive, computed } from 'vue'
export function useFormValidation(initialState, rules) {
const formData = reactive(initialState)
const errors = reactive({})
const validate = () => {
Object.keys(rules).forEach(field => {
const fieldRules = rules[field]
errors[field] = null
for (const rule of fieldRules) {
const isValid = rule.validator(formData[field])
if (!isValid) {
errors[field] = rule.message
break
}
}
})
}
const isValid = computed(() => {
return Object.values(errors).every(error => error === null)
})
return {
formData,
errors,
validate,
isValid
}
}
Usage:
<template>
<form @submit.prevent="submitForm">
<div>
<label>Email</label>
<input v-model="formData.email" type="email">
<span v-if="errors.email" class="error">{{ errors.email }}</span>
</div>
<div>
<label>Password</label>
<input v-model="formData.password" type="password">
<span v-if="errors.password" class="error">{{ errors.password }}</span>
</div>
<button type="submit" :disabled="!isValid">Submit</button>
</form>
</template>
<script setup>
import { useFormValidation } from './useFormValidation'
const initialState = { email: '', password: '' }
const rules = {
email: [
{
validator: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
message: 'Invalid email format'
}
],
password: [
{
validator: (value) => value.length >= 8,
message: 'Password must be at least 8 characters'
}
]
}
const { formData, errors, validate, isValid } = useFormValidation(initialState, rules)
const submitForm = () => {
validate()
if (isValid.value) {
console.log('Form submitted!', formData)
}
}
</script>
Infinite Scrolling
// useInfiniteScroll.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useInfiniteScroll(loadMore, options = {}) {
const { threshold = 100, immediate = true } = options
const isLoading = ref(false)
const isComplete = ref(false)
async function checkScroll() {
if (isLoading.value || isComplete.value) return
const scrollHeight = document.documentElement.scrollHeight
const scrollTop = document.documentElement.scrollTop
const clientHeight = document.documentElement.clientHeight
// If we're near the bottom of the page
if (scrollHeight - scrollTop - clientHeight < threshold) {
isLoading.value = true
try {
const hasMoreData = await loadMore()
isComplete.value = !hasMoreData
} finally {
isLoading.value = false
}
}
}
onMounted(() => {
window.addEventListener('scroll', checkScroll)
if (immediate) checkScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', checkScroll)
})
return {
isLoading,
isComplete
}
}
Organizing Composables
As your application grows, you'll need a strategy to organize composables:
The index.js
file can re-export all composables for easy imports:
// composables/index.js
export * from './ui/useToast'
export * from './ui/useModal'
export * from './data/useFetch'
// etc.
Then you can import them all from a single location:
import { useToast, useModal, useFetch } from '@/composables'
Summary
Composables are a powerful feature of Vue's Composition API that allow you to:
- Extract and reuse stateful logic between components
- Create clean, modular code with clear separation of concerns
- Combine multiple composables to build higher-level functionality
- Organize code by feature rather than by component lifecycle
By embracing composables, you can make your Vue applications more maintainable, testable, and easier to reason about.
Additional Resources
- Vue.js Official Documentation on Composables
- VueUse - A collection of useful Vue composables
- Pinia - Modern state management for Vue, built with composables
Exercises
- Create a
useLocalStorage
composable that syncs a ref with localStorage - Build a
useDebounce
composable that debounces a value change - Create a
useBreakpoint
composable that tracks the current viewport size - Combine
useFetch
anduseDebounce
to create a search feature with debounced API calls - Create a
useTheme
composable that manages dark/light mode for your application
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)