How Vue Composables Work – Explained with Code Examples

Vue composables are a very helpful tool when developing Vue applications. They give developers an easy way to reuse logic across our applications. In addition to allowing for “stateless” logic (things like formatting or routine calculations), composa...

Jun 14, 2025 - 02:40
 0
How Vue Composables Work – Explained with Code Examples

Vue composables are a very helpful tool when developing Vue applications. They give developers an easy way to reuse logic across our applications. In addition to allowing for “stateless” logic (things like formatting or routine calculations), composables also give us the ability to reuse stateful logic throughout the app.

Before diving into the tutorial below, I want to mention that the documentation for Vue is really good. The page on composables explains the basics really well and will get you 90 percent of the way there. I am writing this article because I think the examples in the docs could go a little deeper in explaining how things can work inside of a composable. I will be reiterating some of the information from the docs, but I will also provide an example of a more complex composable.

Here’s what we’ll cover:

Why Use Composables?

Composables let you reuse stateful logic across your apps. Whenever there is logic that is used in more than two places, we typically want to pull that logic into its own function. Most of the time, that logic is considered “stateless”, meaning that it takes an input and returns an output. The docs mention date formatting, but this could also include something like currency calculations or string validation.

In modern web applications, there are often pieces of logic that require managing state over time. Inside a typical component, we have the ability to adapt the application depending on the “state” of different variables within the component. Sometimes that logic, or at least pieces of that logic, are reused throughout the app.

For example, in an e-commerce application, you might have logic to increase and decrease the quantity of a product a person is adding to their cart. This logic could be used both on the product page, and inside the cart itself.

The look and feel of both those places will be different, so re-using a full component wouldn’t make sense – but we still want to centralize the logic to make the code easier to maintain. That is where Composables come in.

(It is worth noting that not everything needs to be a composable. Logic that is only used in a single component shouldn’t be refactored into a composable until necessary.)

Simple Composable Example

Let’s take a look at a simple counter example. Here is some code for a very simple Counter component.




The output of that component would look like this:

8c39759c-6fd9-4fcf-abdf-f67e672c172f

This works great, but if we end up needing this same counter logic in another component with a completely different look and feel, then we would end up repeating the logic. We can extract the logic into a composable and access the same stateful logic anywhere we need to.

// counter.ts
import { ref } from 'vue'
import type { Ref } from 'vue'

export default function useCounter(): Readonly<{
  count: Ref<number>
  increment: () => void
  decrement: () => void
}> {
  const count: Ref<number> = ref(0)
  const increment = () => {
    count.value++
  }
  const decrement = () => {
    count.value--
  }
  return { count, increment, decrement }
}

Then we update the script tag in the component to use the composable:




Now we can use this logic in multiple components throughout the app.

d90d000e-f309-4b22-9530-8e4614b450ec

You will notice that only the logic is copied and each component still has its own copy of the count state. Using a composable does not mean the state is shared across components, only the stateful logic.

Complex Composable Example

In the Vue docs, they give an example of using a composable to handle async data fetching. There are a couple of issues I have with the example they give. The main one is that the error handling is not robust for real world applications. Given that they just want to showcase a straightforward use of composables, this is understandable. But I wanted to showcase a more realistic implementation.

Util function for fetch

Before getting into the composable, we need to set up a util function for the fetch API. This is because we want to make sure every request throws an error if it fails. The fetch API doesn’t throw an error if the request responds with an error status. We have to check the response.ok in order to verify the status, and then throw an error if necessary.

// utils.ts
export async function handleFetch(url: string, options: RequestInit = {}): Promise<Response> {
  const res = await fetch(url, options)
  if (!res.ok) {
    const err = await res.text()
    throw new Error(err)
  }
  return res
}

useAsyncState Composable

When working with async state, the requests can be in a few different states:

  • Pending

  • Resolved

  • Rejected

In addition to these states, we want to track the data or the error that comes back from the request.

// useAsyncState.ts
import { shallowRef } from 'vue'
import type { Ref } from 'vue'

// Specify a type for the response
export type AsyncState = {
  data: Refnull>
  error: Ref<Error | null>
  isPending: Ref<boolean>
  isResolved: Ref<boolean>
  isRejected: Ref<boolean>
}

export default function useAsyncState<T>(promise: Promise): AsyncState<T> {
  // I used shallowRef instead of ref to avoid deep reactivity
  // I only care about the top-level properties being reactive
  const data = shallowRefnull>(null)
  const error = shallowRef<Error | null>(null)
  const isPending = shallowRef(false)
  const isResolved = shallowRef(false)
  const isRejected = shallowRef(false)

  data.value = null
  error.value = null
  isPending.value = true
  isRejected.value = false
  isResolved.value = false

  promise.then((result) => {
    data.value = result
    isPending.value = false
    isResolved.value = true
  }).catch(err => {
    error.value = err
    isPending.value = false
    isRejected.value = true
  })

  return { data, error, isPending, isResolved, isRejected }
}

This gives a few more explicit properties for the different states, rather than relying on the values in data and error. You’ll also notice that this composable takes in a promise rather than a URL string like the docs show. Different endpoints will have different response types and I wanted to be able to handle those outside of this composable.

Usage in a component

I have set up an endpoint that will wait a random number of seconds before responding either successfully or with an error. My component is calling this endpoint using the composable and using the data from the composable to update the template.

304b8c08-5277-4243-b621-70a7c19edcfd

With the error state showing like this:

7d0c6923-85b9-4971-8f69-d127ffa6c1f4

You can see a working example at https://understanding-composables.pages.dev/.

To make this a bit easier to explain and understand, I am breaking up the

Here we have a method, getRandomResponse that calls an endpoint and returns a promise. That promise is then passed into the useAsyncState when handleMakeRequest is called. That puts the full return value into the randomResponseData ref which we can then use inside the template.

Rather than show the full template, I will just show a few portions of it.

Here you can see two different buttons being used depending on the state. I am using a separate button element to indicate the “loading” state, but in practice you can use the composable properties to set the disabled property of the button and change the text.

        <button
          v-if="
            !randomResponseData?.isPending &&
            !randomResponseData?.error &&
            !randomResponseData?.data
          "
          class="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
          @click="handleMakeRequest"
        >
          Make Request
        button>

        
        <button
          v-if="randomResponseData?.isPending"
          disabled
          class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg opacity-75 cursor-not-allowed flex items-center mx-auto"
        >
          <svg
            class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
            xmlns="http://www.w3.org/2000/svg"
            fill="none"
            viewBox="0 0 24 24"
          >
            <circle
              class="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              stroke-width="4"
            >circle>
            <path
              class="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
            >path>
          svg>
          Loading...
        button>

Here are a couple of the rows from the table:

<tr class="divide-x divide-gray-200">
  <td class="py-4 pr-4 pl-4 text-sm font-medium whitespace-nowrap text-gray-900 sm:pl-0">
    isPending
  td>
  <td
    class="p-4 text-sm whitespace-nowrap text-gray-500"
    :class="randomResponseData?.isPending ? 'bg-blue-500' : 'bg-gray-300'"
  >td>
  <td class="p-4 text-sm whitespace-nowrap text-gray-500">
    {{ randomResponseData?.isPending }}
  td>
tr>

<tr class="divide-x divide-gray-200">
  <td class="py-4 pr-4 pl-4 text-sm font-medium whitespace-nowrap text-gray-900 sm:pl-0">
    data
  td>
  <td
    class="p-4 text-sm whitespace-nowrap text-gray-500"
    :class="randomResponseData?.data ? 'bg-green-500' : 'bg-gray-300'"
  >td>
  <td class="p-4 text-sm whitespace-nowrap text-gray-500">
    {{ unref(randomResponseData?.data)?.msg }}
  td>
tr>

In those tr tags, you can see the template rendering different things depending on the state coming from the composable.

For a more complete look at the code, you can visit the GitHub repo. You can also look at how VueUse, a collection of composables, handles similar functionality: https://vueuse.org/core/useAsyncState/

In a future article, I’ll dive into their implementation.

Conclusion

Composables are an incredibly useful tool in Vue 3. As projects grow in size and scope, knowing how and when to use composables can improve the maintainability of the project over the long term.

The key is identifying when you have stateful logic that needs to be reused across components, then extracting it into a well-structured composable that handles edge cases properly.

For more real world examples you can check out the VueUse library and repo.