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.
<script setup lang=<span class="hljs-string">"ts"</span>>
<span class="hljs-keyword">import</span> { ref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { Ref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-keyword">const</span> count: Ref<<span class="hljs-built_in">number</span>> = ref(<span class="hljs-number">0</span>)
<span class="hljs-keyword">const</span> increment = <span class="hljs-function">() =></span> {
count.value++
}
<span class="hljs-keyword">const</span> decrement = <span class="hljs-function">() =></span> {
count.value--
}
</script>
<template>
<div <span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-teal-100 border-2 border-gray-800 rounded-xl p-4 w-64"</span>>
<div <span class="hljs-keyword">class</span>=<span class="hljs-string">"text-center mb-4"</span>>
<span <span class="hljs-keyword">class</span>=<span class="hljs-string">"text-lg font-medium text-gray-800"</span>>Count: {{ count }}</span>
</div>
<div <span class="hljs-keyword">class</span>=<span class="hljs-string">"flex gap-2 justify-center"</span>>
<button
<span class="hljs-meta">@click</span>=<span class="hljs-string">"decrement"</span>
<span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-red-100 border-2 border-gray-800 rounded px-4 py-0 text-gray-800 font-medium hover:bg-red-500 transition-colors"</span>
>
-
</button>
<button
<span class="hljs-meta">@click</span>=<span class="hljs-string">"count = 0"</span>
<span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-gray-100 border-2 border-gray-800 rounded px-4 py-0 text-gray-800 font-medium hover:bg-gray-300 transition-colors"</span>
>
Reset
</button>
<button
<span class="hljs-meta">@click</span>=<span class="hljs-string">"increment"</span>
<span class="hljs-keyword">class</span>=<span class="hljs-string">"bg-green-100 border-2 border-gray-800 rounded px-4 py-0 text-gray-800 font-medium hover:bg-green-500 transition-colors"</span>
>
+
</button>
</div>
</div>
</template>
The output of that component would look like this:
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.
<span class="hljs-comment">// counter.ts</span>
<span class="hljs-keyword">import</span> { ref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { Ref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useCounter</span>(<span class="hljs-params"></span>): <span class="hljs-title">Readonly</span><</span>{
count: Ref<<span class="hljs-built_in">number</span>>
increment: <span class="hljs-function">() =></span> <span class="hljs-built_in">void</span>
decrement: <span class="hljs-function">() =></span> <span class="hljs-built_in">void</span>
}> {
<span class="hljs-keyword">const</span> count: Ref<<span class="hljs-built_in">number</span>> = ref(<span class="hljs-number">0</span>)
<span class="hljs-keyword">const</span> increment = <span class="hljs-function">() =></span> {
count.value++
}
<span class="hljs-keyword">const</span> decrement = <span class="hljs-function">() =></span> {
count.value--
}
<span class="hljs-keyword">return</span> { count, increment, decrement }
}
Then we update the script tag in the component to use the composable:
<script setup>
<span class="hljs-keyword">import</span> { useCounter } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/counter.ts'</span>
<span class="hljs-keyword">const</span> { count, increment, decrement } = useCounter()
</script>
<template>
...
</template>
Now we can use this logic in multiple components throughout the app.
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.
<span class="hljs-comment">// utils.ts</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handleFetch</span>(<span class="hljs-params">url: <span class="hljs-built_in">string</span>, options: RequestInit = {}</span>): <span class="hljs-title">Promise</span><<span class="hljs-title">Response</span>> </span>{
<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(url, options)
<span class="hljs-keyword">if</span> (!res.ok) {
<span class="hljs-keyword">const</span> err = <span class="hljs-keyword">await</span> res.text()
<span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(err)
}
<span class="hljs-keyword">return</span> 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.
<span class="hljs-comment">// useAsyncState.ts</span>
<span class="hljs-keyword">import</span> { shallowRef } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { Ref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-comment">// Specify a type for the response</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> AsyncState<T> = {
data: Ref<T | <span class="hljs-literal">null</span>>
error: Ref<<span class="hljs-built_in">Error</span> | <span class="hljs-literal">null</span>>
isPending: Ref<<span class="hljs-built_in">boolean</span>>
isResolved: Ref<<span class="hljs-built_in">boolean</span>>
isRejected: Ref<<span class="hljs-built_in">boolean</span>>
}
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useAsyncState</span><<span class="hljs-title">T</span>>(<span class="hljs-params">promise: <span class="hljs-built_in">Promise</span><T></span>): <span class="hljs-title">AsyncState</span><<span class="hljs-title">T</span>> </span>{
<span class="hljs-comment">// I used shallowRef instead of ref to avoid deep reactivity</span>
<span class="hljs-comment">// I only care about the top-level properties being reactive</span>
<span class="hljs-keyword">const</span> data = shallowRef<T | <span class="hljs-literal">null</span>>(<span class="hljs-literal">null</span>)
<span class="hljs-keyword">const</span> error = shallowRef<<span class="hljs-built_in">Error</span> | <span class="hljs-literal">null</span>>(<span class="hljs-literal">null</span>)
<span class="hljs-keyword">const</span> isPending = shallowRef(<span class="hljs-literal">false</span>)
<span class="hljs-keyword">const</span> isResolved = shallowRef(<span class="hljs-literal">false</span>)
<span class="hljs-keyword">const</span> isRejected = shallowRef(<span class="hljs-literal">false</span>)
data.value = <span class="hljs-literal">null</span>
error.value = <span class="hljs-literal">null</span>
isPending.value = <span class="hljs-literal">true</span>
isRejected.value = <span class="hljs-literal">false</span>
isResolved.value = <span class="hljs-literal">false</span>
promise.then(<span class="hljs-function">(<span class="hljs-params">result</span>) =></span> {
data.value = result
isPending.value = <span class="hljs-literal">false</span>
isResolved.value = <span class="hljs-literal">true</span>
}).catch(<span class="hljs-function"><span class="hljs-params">err</span> =></span> {
error.value = err
isPending.value = <span class="hljs-literal">false</span>
isRejected.value = <span class="hljs-literal">true</span>
})
<span class="hljs-keyword">return</span> { 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.
With the error state showing like this:
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 <script>
tag and the <template>
sections of the component.
Script
<script lang=<span class="hljs-string">"ts"</span> setup>
<span class="hljs-keyword">import</span> { ref, unref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { Ref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>
<span class="hljs-keyword">import</span> { useAsyncState } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/composables'</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { AsyncState } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/composables'</span>
<span class="hljs-keyword">import</span> { handleFetch } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/utils'</span>
<span class="hljs-keyword">interface</span> RandomResponse {
msg: <span class="hljs-built_in">string</span>
}
<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getRandomResponse</span>(<span class="hljs-params"></span>): <span class="hljs-title">Promise</span><<span class="hljs-title">RandomResponse</span>> </span>{
<span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> handleFetch(<span class="hljs-string">'https://briancbarrow.com/api/random'</span>)
<span class="hljs-keyword">const</span> text = <span class="hljs-keyword">await</span> response.text()
<span class="hljs-keyword">return</span> { msg: text }
}
<span class="hljs-keyword">const</span> randomResponseData: Ref<AsyncState<RandomResponse> | <span class="hljs-literal">null</span>> = ref(<span class="hljs-literal">null</span>)
<span class="hljs-keyword">const</span> handleMakeRequest = <span class="hljs-keyword">async</span> () => {
<span class="hljs-keyword">const</span> data = getRandomResponse()
randomResponseData.value = useAsyncState(data)
}
</script>
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.
<span class="hljs-tag"><<span class="hljs-name">button</span>
<span class="hljs-attr">v-if</span>=<span class="hljs-string">"
!randomResponseData?.isPending &&
!randomResponseData?.error &&
!randomResponseData?.data
"</span>
<span class="hljs-attr">class</span>=<span class="hljs-string">"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"</span>
@<span class="hljs-attr">click</span>=<span class="hljs-string">"handleMakeRequest"</span>
></span>
Make Request
<span class="hljs-tag"></<span class="hljs-name">button</span>></span>
<span class="hljs-comment"><!-- Loading State Button --></span>
<span class="hljs-tag"><<span class="hljs-name">button</span>
<span class="hljs-attr">v-if</span>=<span class="hljs-string">"randomResponseData?.isPending"</span>
<span class="hljs-attr">disabled</span>
<span class="hljs-attr">class</span>=<span class="hljs-string">"px-6 py-3 bg-blue-600 text-white font-medium rounded-lg opacity-75 cursor-not-allowed flex items-center mx-auto"</span>
></span>
<span class="hljs-tag"><<span class="hljs-name">svg</span>
<span class="hljs-attr">class</span>=<span class="hljs-string">"animate-spin -ml-1 mr-3 h-5 w-5 text-white"</span>
<span class="hljs-attr">xmlns</span>=<span class="hljs-string">"http://www.w3.org/2000/svg"</span>
<span class="hljs-attr">fill</span>=<span class="hljs-string">"none"</span>
<span class="hljs-attr">viewBox</span>=<span class="hljs-string">"0 0 24 24"</span>
></span>
<span class="hljs-tag"><<span class="hljs-name">circle</span>
<span class="hljs-attr">class</span>=<span class="hljs-string">"opacity-25"</span>
<span class="hljs-attr">cx</span>=<span class="hljs-string">"12"</span>
<span class="hljs-attr">cy</span>=<span class="hljs-string">"12"</span>
<span class="hljs-attr">r</span>=<span class="hljs-string">"10"</span>
<span class="hljs-attr">stroke</span>=<span class="hljs-string">"currentColor"</span>
<span class="hljs-attr">stroke-width</span>=<span class="hljs-string">"4"</span>
></span><span class="hljs-tag"></<span class="hljs-name">circle</span>></span>
<span class="hljs-tag"><<span class="hljs-name">path</span>
<span class="hljs-attr">class</span>=<span class="hljs-string">"opacity-75"</span>
<span class="hljs-attr">fill</span>=<span class="hljs-string">"currentColor"</span>
<span class="hljs-attr">d</span>=<span class="hljs-string">"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"</span>
></span><span class="hljs-tag"></<span class="hljs-name">path</span>></span>
<span class="hljs-tag"></<span class="hljs-name">svg</span>></span>
Loading...
<span class="hljs-tag"></<span class="hljs-name">button</span>></span>
Here are a couple of the rows from the table:
<span class="hljs-tag"><<span class="hljs-name">tr</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"divide-x divide-gray-200"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">td</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"py-4 pr-4 pl-4 text-sm font-medium whitespace-nowrap text-gray-900 sm:pl-0"</span>></span>
isPending
<span class="hljs-tag"></<span class="hljs-name">td</span>></span>
<span class="hljs-tag"><<span class="hljs-name">td</span>
<span class="hljs-attr">class</span>=<span class="hljs-string">"p-4 text-sm whitespace-nowrap text-gray-500"</span>
<span class="hljs-attr">:class</span>=<span class="hljs-string">"randomResponseData?.isPending ? 'bg-blue-500' : 'bg-gray-300'"</span>
></span><span class="hljs-tag"></<span class="hljs-name">td</span>></span>
<span class="hljs-tag"><<span class="hljs-name">td</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"p-4 text-sm whitespace-nowrap text-gray-500"</span>></span>
{{ randomResponseData?.isPending }}
<span class="hljs-tag"></<span class="hljs-name">td</span>></span>
<span class="hljs-tag"></<span class="hljs-name">tr</span>></span>
<span class="hljs-tag"><<span class="hljs-name">tr</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"divide-x divide-gray-200"</span>></span>
<span class="hljs-tag"><<span class="hljs-name">td</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"py-4 pr-4 pl-4 text-sm font-medium whitespace-nowrap text-gray-900 sm:pl-0"</span>></span>
data
<span class="hljs-tag"></<span class="hljs-name">td</span>></span>
<span class="hljs-tag"><<span class="hljs-name">td</span>
<span class="hljs-attr">class</span>=<span class="hljs-string">"p-4 text-sm whitespace-nowrap text-gray-500"</span>
<span class="hljs-attr">:class</span>=<span class="hljs-string">"randomResponseData?.data ? 'bg-green-500' : 'bg-gray-300'"</span>
></span><span class="hljs-tag"></<span class="hljs-name">td</span>></span>
<span class="hljs-tag"><<span class="hljs-name">td</span> <span class="hljs-attr">class</span>=<span class="hljs-string">"p-4 text-sm whitespace-nowrap text-gray-500"</span>></span>
{{ unref(randomResponseData?.data)?.msg }}
<span class="hljs-tag"></<span class="hljs-name">td</span>></span>
<span class="hljs-tag"></<span class="hljs-name">tr</span>></span>
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.
Source: freeCodeCamp Programming Tutorials: Python, JavaScript, Git & MoreÂ