Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 012 / 100

Composables: reusable logic — what developers should know

Vue's answer to custom hooks.

Expona Admin7 min read

Introduction

If you've worked with React, you've likely reached for custom hooks to share stateful logic across components. Vue's Composition API brings the same capability through composables: reusable logic — what developers should know when building maintainable applications. A composable is simply a JavaScript function that leverages Vue's reactivity system to encapsulate and return reactive state, computed values, and methods. They're the closest Vue equivalent to custom hooks, and they solve the same problem: keeping your components lean and your logic reusable.

Over the next ten minutes, we'll walk through what composables are, build a real-world example, and explore why naming conventions and libraries like VueUse matter in production codebases.

What composables are: functions that use composition API and return state

A composable is a function that wraps Vue's Composition API primitives—ref, reactive, computed, watch—and returns reactive state to be used in components. Unlike mixins or render props, composables are straightforward functions. They're synchronous by default, though they often handle async operations internally.

The shape is always similar: a function that sets up some reactive data, potentially runs side effects, and hands back the pieces that need to be reactive in the calling component. Nothing is magic. No inheritance chains, no implicit context binding, no scoping confusion. Just functions.

Consider why this matters in a production codebase. You build a feature to track whether a user is scrolling past a certain threshold. Rather than duplicate that logic in three different pages, you write it once as a composable. Every component that needs scroll detection uses the same function, gets the same behavior, and the logic lives in one place. When a bug appears or requirements shift, you fix it once.

This is especially valuable when multiple components need to work with the same external resource—an API, localStorage, a WebSocket connection, or an event listener. Each consumer gets its own instance of the composable's state, so they don't collide. That isolation is critical in applications where state leakage across components causes subtle, hard-to-debug bugs.

Building a real-world fetch composable

Let's build a concrete example: a composable that handles data fetching. This is one of the most common patterns we see in production code, and it demonstrates all the key concepts.

typescript

import { ref, computed } from 'vue'

export function useFetch<T>(url: string) {
  const data = ref<T | null>(null)
  const error = ref<unknown>(null)
  const isLoading = ref(true)

  fetch(url)
    .then(r => r.json())
    .then(d => {
      data.value = d
      error.value = null
    })
    .catch(e => {
      error.value = e
      data.value = null
    })
    .finally(() => {
      isLoading.value = false
    })

  return { data, error, isLoading }
}

Now a component can use it:

typescript

<script setup lang="ts">
import { useFetch } from '@/composables/useFetch'

interface User {
  id: number
  name: string
}

const { data: user, error, isLoading } = useFetch<User>('/api/user/123')
</script>

<template>
  <div v-if="isLoading">Loading...</div>
  <div v-else-if="error">Failed to load user</div>
  <div v-else>
    <h2>{{ user?.name }}</h2>
  </div>
</template>

The composable handles the async work. The component stays simple and declarative. If five components need to fetch user data, they all call useFetch and get their own isolated state. They don't share refs across instances—that's Vue's reactivity system handling the plumbing.

In real production codebases, we often enhance this further. Add request cancellation via AbortController to avoid memory leaks when components unmount. Add retry logic with exponential backoff. Support request parameters that are themselves reactive, so changing the URL triggers a new fetch. These additions stay inside the composable. The consumer—the component—never sees the complexity.

Here's a more sophisticated version with reactive URL support:

typescript

import { ref, watch, computed, unref, Ref } from 'vue'

export function useFetch<T>(url: string | Ref<string>) {
  const data = ref<T | null>(null)
  const error = ref<unknown>(null)
  const isLoading = ref(false)
  let controller: AbortController

  const execute = async () => {
    isLoading.value = true
    controller = new AbortController()
    try {
      const response = await fetch(unref(url), {
        signal: controller.signal
      })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      data.value = await response.json()
      error.value = null
    } catch (e) {
      if (e instanceof DOMException && e.name === 'AbortError') return
      error.value = e
      data.value = null
    } finally {
      isLoading.value = false
    }
  }

  watch(() => unref(url), () => execute(), { immediate: true })

  const abort = () => controller?.abort()

  return { data, error, isLoading, execute, abort }
}

Now if the URL ref changes, the fetch automatically re-runs. If the component unmounts and abort is called, the request cancels cleanly. The calling component doesn't need to know about any of this.

Naming conventions: start with use

Vue's ecosystem follows a strict naming convention: all composables start with use. This isn't arbitrary. It's a signal to readers—and to linters, IDEs, and code review tools—that a function is a composable and follows certain rules.

Why this matters: when you see useFetch, you immediately know it returns reactive state, may have side effects, and should be called at the top level of your component (inside <script setup> or the setup function). You wouldn't call useFetch inside a loop or conditional—that violates Vue's reactivity rules. The naming convention is a contract.

Good composable names describe what they do: useMouse, useLocalStorage, useIntersectionObserver, useAsync. Avoid names like getUser or fetchData; those sound like plain functions. Reserve use for functions that return reactive state or that depend on Vue's lifecycle.

In your codebase, enforce this with an ESLint rule. If a function is exported from composables/, it must start with use. This catches mistakes early and keeps the pattern consistent across teams.

VueUse: a library of ready-made composables

VueUse is a collection of over 200 community-maintained composables for common tasks. Rather than write useFetch yourself, you might use useFetch from VueUse. Rather than wire up mouse tracking, you use useMouse.

We recommend exploring VueUse before building custom composables. Often, what you need already exists and is battle-tested across thousands of applications. The library is well-documented, actively maintained, and covers everything from DOM interactions to async operations to browser APIs.

Here's an example—detecting when an element enters the viewport:

typescript

import { useIntersectionObserver } from '@vueuse/core'
import { ref } from 'vue'

<script setup>
const target = ref(null)
const { isVisible } = useIntersectionObserver(target)
</script>

<template>
  <div ref="target">
    <p v-if="isVisible">Now visible on screen</p>
  </div>
</template>

VueUse handles the observer setup, cleanup, and reactivity. You use the composable. That's the win: less code, fewer bugs, and you inherit improvements as VueUse updates.

That said, don't treat VueUse as a black box. Read its source code. When you need something custom—business logic specific to your app, or a composable that wraps multiple VueUse utilities—build it yourself. The pattern is consistent whether you're using VueUse or your own composables.

Common mistakes

Calling composables conditionally: Vue requires that composables be called at the top level of your setup function or <script setup> block. Don't call them inside if statements or loops. Vue's reactivity system relies on consistent call order.

Forgetting to return the refs: if a composable sets up reactive state but doesn't return it, the component can't use it. Always be explicit about what the composable returns.

Mixing side effects into the return value: keep what a composable returns focused. If it returns five things, five things should be used. Returning internal utility functions that aren't needed by consumers clutters the API.

Not cleaning up subscriptions: if a composable sets up a listener (event, watch, interval), clean it up on unmount. Use onBeforeUnmount or return a cleanup function. Leaking listeners cause memory issues and ghost subscriptions.

Composables that depend on implicit context: a composable should work standalone. If it silently depends on a parent component providing something via inject, that's hidden coupling. Make dependencies explicit in parameters.

Key takeaways

  • Composables are functions that use Vue's Composition API and return reactive state—your tool for sharing logic across components.
  • They solve the same problem that custom hooks solve in React: reusability, isolation, and keeping components simple.
  • Start composable names with use. This signals that the function returns reactive state and must be called at the top level of your component.
  • Explore VueUse before writing custom composables. It covers common patterns and is maintained by the community.
  • Always clean up side effects (listeners, intervals, watchers) to avoid memory leaks and silent bugs.
  • Keep composables focused. Return only what the consumer needs. Avoid internal APIs and implicit context.
  • Composables are testable: mock their return values and assert behavior in unit tests. They're just functions.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles