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,

  1. Consider we are sharing this composable with component A and component B.
  2. In component A, let's assume an external API is called and the global loading is set to true using the setGlobalLoading function.
  3. Before the API request is complete, we visit component B and expect the isGlobalLoading value to be true 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.

  1. 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>
  1. 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.