Composables are basic javasctipt functions on steroids with Vue 3's reactivity ✨.
We use mixins in Vue 2 for reusability. It's great, but had some downsides, like property name collision, poor visibility on properties, etc..
Introduction of ref
reactive
and much more API's in Vue 3 provides a huge deal in extracting our logic and maintaining
its reactivity across using shared states and much more.
# Defining a composable
Create a javascript / typescript file and declare a function. A common convention is to have use
as prefix for composables. Example,
// useConsole.js
const useConsole = () => {
function log (value) {
console.log(value)
}
return { log }
}
This can be imported into any Vue component / composable to use the custom log method.
<!-- anyComponent.vue -->
<script setup>
import { useConsole } from '../some/path/useConsole.js'
const { log } = useConsole()
</script>
<template>
<button @click="log('Hello World!')">
Log Content
</button>
</template>
// useAnotherComposable.js
import { useConsole } from '../some/path/useConsole.js'
const useAnotherComposable = () => {
const { log } = useConsole()
function anyMethod () {
log('anyMethod Called!')
}
return { anyMethod }
}
# Lifecycle Hooks
Lifecycle hooks can be used inside composables. Because of which, it should only be called inside a setup (<script setup></script>
). Outside setup, the lifecycle hooks won't
get triggered.
const useConsole = () => {
onMounted(() => log('Triggered!'))
...
}
If you are defining an event listener inside onMounted
inside a composable, make sure to destroy it using onBeforeUnmount
.
# Stateful Composables
So far, we have discussed about composables which does not have any state.
It can be differentiated into local and global states. Local states are re-declared everytime the composable is called. Example,
# Local State
// useLoadingStates.js
const useLoadingStates = () => {
const isGlobalLoading = ref(false)
function setGlobalLoading () {
isGlobalLoading.value = true
}
function resetGlobalLoading () {
isGlobalLoading.value = false
}
return {
isGlobalLoading,
/* -- */
setGlobalLoading,
resetGlobalLoading
}
}
States are nothing but reactive variables. It is similar to the properties that are defined inside data
in options API.
We have a composable that tracks the global loading state of an application. We have a state called isGlobalLoading
set to false
by default.
Local states are only useful to handle states that are internal to the composable. Shared local states doesn't hold the value across the components. Example,
- Consider we are sharing this composable with component A and component B.
- In component A, let's assume an external API is called and the global loading is set to
true
using thesetGlobalLoading
function. - Before the API request is complete, we visit component B and expect the
isGlobalLoading
value to betrue
and still loading.
But it is not the case. Internal states reset everytime the composable is called again.
<!-- ComponentA.vue -->
<script setup>
const { setGlobalLoading, resetGlobalLoading } = useLoadingStates()
onMounted(() => {
setGlobalLoading()
fetch('/some/external/api')
.then((response) => handleSuccess())
.catch((error) => throwSomeError())
.finally(() => resetGlobalLoading())
})
</script>
<template>
...
</template>
<!-- ComponentB.vue -->
<script setup>
const { isGlobalLoading } = useLoadingStates()
// "isGlobalLoading" is false, since "useLoadingStates()" is called again,
// which re-declares/re-initiates all the local state variables
</script>
<template>
...
</template>
A composable is just a javascript function. Any function that handles its state internally gets reset everytime it is called again.
# Global State
To define a global state inside a composable, all we need is to declare the state outside the composable function.
// useLoadingStates.js
const isGlobalLoading = ref(false)
const useLoadingStates = () => {
function setGlobalLoading () {
isGlobalLoading.value = true
}
function resetGlobalLoading () {
isGlobalLoading.value = false
}
return {
isGlobalLoading,
/* -- */
setGlobalLoading,
resetGlobalLoading
}
}
# Immutable States
In the above example, isGlobalLoading
is being returned from the composable. Since it is a ref
, it can be modified.
To make a state immutable, we can return the state as a computed property.
const isGlobalLoading = ref(false)
const useLoadingStates = () => {
...
return {
isGlobalLoading: computed(() => isGlobalLoading.value),
/* -- */
setGlobalLoading,
resetGlobalLoading
}
}
Computed properites are getters
by default. So it is not possible to mutate, unless an explicit setter is defined.
# Composition API – Composables within setup
Composables are not only used as an external file that are shared across components. They can also be used to write clean and organized code within a Vue component.
Options API, had a definite structure (data, watch, computed, methods, etc..), but once the component gets larger, it was always difficult to traverse between properties and its methods.
- When composition API was introduced, we are tempted to write states near the functions where they are used. Example,
<!-- ❌ Not recommended -->
<script setup>
const tasksList = ref([])
const taskItem = ref({})
function fetchTasks () {
...
}
function editTask (id) {
...
}
const currentPage = ref(0)
const prevPage = ref(null)
const nextPage = ref(null)
function paginate () {
...
}
</script>
- We hoist the states of all the functions to the top of our setup. Example,
<!-- ❌ Not recommended -->
<script setup>
const tasksList = ref([])
const taskItem = ref({})
const currentPage = ref(0)
const prevPage = ref(null)
const nextPage = ref(null)
function fetchTasks () {
...
}
function editTask (id) {
...
}
function paginate () {
...
}
</script>
Since in options API, the data, computed and methods are divided into their respective spaces, these thought processes are not completely wrong. It's just that there is a better way to write it in the composition API.
We can use composables within the setup of a component. These are usually termed as inline composables.
# Inline composables
Writing iniline composables makes the component clean. Treating each composable as an individual unit makes the logic more readable and traversable.
<!-- TaskPage.vue -->
<script setup>
onMounted(() => fetchTasks())
const {
setGlobalLoading,
resetGlobalLoading
} = useLoadingStates()
const {
tasksList,
taskItem
fetchTasks,
editTask,
} = useTasks()
function useTasks () {
const tasksList = ref([])
const taskItem = ref({})
async function fetchTasks () {
setGlobalLoading()
fetch('/api/tasks')
.then((tasks) => setTasks(tasks))
.catch((error) => throwSomeError(error))
.finally(() => resetGlobalLoading())
}
function setTasks (list) {
tasksList.value = list
}
function editTask (id) {
taskItem.value = findTask(id)
}
function findTask (id) {
return tasksList.find(task => task.id === id) ?? {}
}
return {
tasksList,
taskItem,
/* -- */
fetchTasks,
editTask
}
}
</script>
Each composable holds its own state. Export only the states and functions that are needed.
<template>
...
<div v-for="task in tasksList" :key="task.id">
<h2>Task title: {{ task.title }}</h2>
...
</div>
...
</template>
And that's a wrap.