Have you ever copied and pasted the same fetch, form validation, or theme logic into ten different components? Duplicated code isn't just ugly — it's a hidden cost. Every copy is a place where a bug can hide, and every change requires ten manual edits. At Meteora Web, we see this every day in the projects we receive: thousand-line components, repeated logic, and maintenance that becomes a nightmare. Vue 3 composables solve this problem at the root.
Why are Vue 3 composables the answer to duplicated code?
The Composition API, introduced with Vue 3, brought a new paradigm: logic can be extracted into reusable functions we call composables. They aren't an external library, they don't require plugins: they're simply JavaScript functions that use Vue's reactive APIs (ref, reactive, computed, watch) to encapsulate state and behavior.
Think of a composable as a specialized toolbox. Instead of having one drawer full of screwdrivers, hammers, and nails (the component), each box contains only what's needed for a specific task. Want to manage dark mode? Grab the useTheme box. Want to load data from an API? Grab useFetch. Each box is independent, testable, and can be used in any component.
The difference from mixins (Vue 2's solution) is substantial: mixins merged properties into the component, creating naming conflicts and making it hard to understand where a variable came from. Composables, on the other hand, explicitly return what you need, making code readable and predictable. If you know React custom hooks, you're already halfway there: the philosophy is identical, but the implementation leverages Vue's reactivity.
How does a composable work in practice?
A composable is a function that uses Vue's APIs. Here's a minimal example to manage a counter:
// composables/useCounter.js
import { ref } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
return { count, increment, decrement, reset }
}
In a component, you use it like this:
Sponsored Protocol
Count: {{ count }}
The magic is that count is reactive: when it changes, the template updates automatically. And if two components use the same composable, each has its own independent instance. No interference, no conflicts.
What problems does a composable solve compared to a component?
A component can encapsulate logic and template, but it's not always the right choice. If you have logic that doesn't have a direct visual representation (like data fetching), forcing it into a component means creating useless wrappers and prop drilling. A composable, instead, is pure logic: you use it where you need it, without polluting the template.
Moreover, composables are testable in isolation. You can write unit tests for fetch or validation logic without mounting a component, with reduced execution times and less complexity. We do this daily, as we documented in our guide on React Testing Library: the same targeted testing philosophy applies perfectly to Vue composables.
How to create a useFetch composable for API calls?
The most common use case is data fetching. How many times have you written the same code to load data, manage loading state, and handle errors? With a composable, you write it once. Here's a complete, working example:
// composables/useFetch.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useFetch(url, options = {}) {
const data = ref(null)
const error = ref(null)
const isLoading = ref(false)
let controller = null
const fetchData = async () => {
isLoading.value = true
error.value = null
controller = new AbortController()
try {
const response = await fetch(url, { ...options, signal: controller.signal })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
data.value = await response.json()
} catch (err) {
if (err.name !== 'AbortError') error.value = err.message
} finally {
isLoading.value = false
}
}
onMounted(fetchData)
onUnmounted(() => {
if (controller) controller.abort()
})
return { data, error, isLoading, refetch: fetchData }
}
Now, in any component, you can load data with a few lines:
Sponsored Protocol
Loading...
Error: {{ error }}
- {{ product.name }}
Notice how we also handled the abort of the request when the component unmounts: we avoid memory leaks and unnecessary requests. This is the level of attention that makes a difference in production.
How to manage global state with a composable?
So far, we've seen composables that create local state for each component. But if you want to share state between multiple components (like the authenticated user or the cart), you can use a singleton pattern. Just define the state outside the function:
// composables/useUser.js
import { ref } from 'vue'
const user = ref(null)
const isAuthenticated = computed(() => !!user.value)
export function useUser() {
const setUser = (newUser) => { user.value = newUser }
const clearUser = () => { user.value = null }
return { user, isAuthenticated, setUser, clearUser }
}
Now, any component that calls useUser() shares the same state. It's a lightweight solution that replaces state management libraries like Pinia for simple cases. We often use it to manage user sessions or theme preferences.
What are the best practices for writing robust composables?
Writing a composable is easy. Writing it well requires discipline. Here are the rules we follow, and we recommend you adopt them:
- Name it with "use": the convention is universal and makes code self-documenting. useFetch, useLocalStorage, useDebounce, etc.
- Always return an object: even if it's a single property, an object allows adding features without breaking existing code.
- Accept reactive parameters: if your composable must react to an input change, accept a ref or use toRefs for destructuring. Example: useFetch(userId) where userId is a ref.
- Clean up resources: if you use event listeners, timers, or fetch, release them in onUnmounted. Forgotten resources are the main cause of subtle bugs.
- Document parameters and return values: JSDoc is your friend. A well-documented composable is one that others (and future you) will use without errors.
A common mistake is trying to make the composable too generic. A composable that does everything is a composable that does nothing well. Better small, focused composables that you can compose together. Composition is the real strength of this pattern.
Sponsored Protocol
How to test a composable in isolation?
Testing a composable is simple: call it in a controlled environment and verify the state. Here's an example with Vitest:
// __tests__/useCounter.spec.js
import { describe, it, expect } from 'vitest'
import { useCounter } from '../composables/useCounter'
describe('useCounter', () => {
it('increments the counter', () => {
const { count, increment } = useCounter(5)
increment()
expect(count.value).toBe(6)
})
})
No need to mount a component: you test pure logic, fast and reliable. This approach reduces testing time and increases confidence in the code. If you want to dive deeper into component testing, we recommend reading our guide on React Testing Library: the principles are the same, applied to another framework.
How to integrate composables with TypeScript for strong typing?
If you use TypeScript, composables become even more powerful. You can define interfaces for parameters and return values, making code self-documenting and reducing errors at compile-time. Here's a typed example:
// composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'
export function useLocalStorage(key: string, initialValue: T): [Ref, (value: T) => void] {
const storedValue = ref(JSON.parse(localStorage.getItem(key) || JSON.stringify(initialValue)))
const setValue = (value: T) => {
storedValue.value = value
localStorage.setItem(key, JSON.stringify(value))
}
watch(storedValue, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
})
return [storedValue, setValue]
}
Now, when you use this composable, TypeScript knows what type of data you're handling. No more runtime errors due to malformed data. Typing isn't optional: it's an investment that pays off in maintenance and robustness.
Sponsored Protocol
The official Vue documentation offers excellent examples and guidelines. We recommend checking it out for deeper insights: Composables - Vue.js.
What are the real-world use cases that justify a composable?
Not everything deserves a composable. The golden rule is: if the logic is used in more than one component, extract it. Here are the most frequent cases we see in our projects:
- Data fetching: useFetch, useMutation, useQuery to manage APIs.
- Form validation: useForm to manage state, errors, and submission.
- User preferences: useTheme, useLanguage to save and restore settings.
- Geolocation: useGeolocation to get the position and manage permissions.
- Intersection Observer: useIntersectionObserver for lazy loading or scroll animations.
- Timers and debounce: useDebounce, useThrottle to optimize high-frequency events.
A concrete example we implemented for an e-commerce client: a useCart composable that managed the cart, syncing it with the backend and localStorage. A single point of modification to update prices, quantities, and availability, and all components (header, product page, cart) updated automatically. The result? Fewer bugs, faster development, and maintenance that wasn't scary.
How to avoid common mistakes with composables?
Even the best patterns can be misused. Here are the mistakes we see most often:
- Using composables for logic that isn't reusable: if you use it in only one component, it's over-engineering. Keep it in the component.
- Not managing resource cleanup: abandoned timers, listeners, and fetches cause memory leaks and unpredictable behavior.
- Ignoring parameter reactivity: if you pass a primitive value, the composable won't react to changes. Use ref or toRefs.
- Creating circular dependencies: a composable that uses another composable that uses the first one. Avoid infinite loops.
- Not testing: a composable is pure logic, test it as such. If you don't test it, you don't know it works.
At Meteora Web, we built a proprietary platform for social media management using composables extensively. Every feature (publishing, calendar, invoicing) is an independent, tested, and reusable composable. This allowed us to scale the project without rewriting code, maintaining consistent quality.
Sponsored Protocol
If you want to see how composables fit into a broader architecture, we recommend reading our guide on Vue.js 3 and Composition API.
What to do next
Here are concrete actions to start using composables today:
- Identify duplicated code: look for repeated logic in your components (fetch, forms, timers). It's the perfect candidate for a composable.
- Create your first composable: start with something simple like useCounter or useFetch. Copy the examples from this guide and adapt them to your case.
- Incremental refactoring: don't rewrite everything at once. Extract one composable at a time, test, and verify nothing breaks.
- Write tests for composables: use Vitest to test logic in isolation. It's an investment that pays off immediately.
- Share knowledge: document your composables and share them with the team. Consistency is key to maintainability.
Composables aren't a fad: they're the right way to write Vue 3 applications. They let you reduce duplicated code, improve testability, and increase development speed. And, as we always say, a website is measured in revenue, not compliments. Less time debugging means more time improving the product.