I recently had a great discussion with my team at Vue Storefront about patterns for writing Vue composables. In the case of our system, composables are responsible for storing the main business logic (like calculations, actions, processess) so they are a crucial part of the application. Unfortunately over the time, we didn't have that much time to create some sort of Contract for writing Composables and because of that few of our composables are not really composables 😉
I am really happy that right now we have this time to refactor our approach to building new composables so that they are maintainable, easy to test, and actually useful.
In this article, I will summarise ideas that we have created and also merge them with good practices and design patterns that I read about in few articles.
So this article will be divided into three sections:
General Design Patterns
My recommendations
Further reading
Enjoy and also, let me know what patterns and practices you are using in your projects 🚀
General Design Patterns
The best source in my opinion to learn about patterns for building composables is actually a Vue.js Documentation that you can check out here
Basic Composable
Vue documentation shows following example of a useMouse composable:
// mouse.js
import { ref, onMounted, onUnmounted } from 'vue'
// by convention, composable function names start with "use"
export function useMouse() {
// state encapsulated and managed by the composable
const x = ref(0)
const y = ref(0)
// a composable can update its managed state over time.
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
// a composable can also hook into its owner component's
// lifecycle to setup and teardown side effects.
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
// expose managed state as return value
return { x, y }
}
That can be later used in the component like following:
<script setup>
import { useMouse } from './mouse.js'
const { x, y } = useMouse()
</script>
<template>Mouse position is at: {{ x }}, {{ y }}</template>
Async composables
For fetching data, Vue recommends following composable structure:
import { ref, watchEffect, toValue } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
watchEffect(() => {
// reset state before fetching..
data.value = null
error.value = null
// toValue() unwraps potential refs or getters
fetch(toValue(url))
.then((res) => res.json())
.then((json) => (data.value = json))
.catch((err) => (error.value = err))
})
return { data, error }
}
That can be then used in the component like following:
<script setup>
import { useFetch } from './fetch.js'
const { data, error } = useFetch('...')
</script>
Composables contract
Based on the examples above, here is the contract that all composables should follow:
Composable file names should start with use for example useSomeAmazingFeature.ts
It can accept input arguments that can be primitive types like strings or can accept refs and getters but it requires to use toValue helper
Composable should return a ref value that can be accessed after destructuring the composable like const { x, y } = useMouse()
Composables can hold global state that can be access and modified across the app.
Composable can cause side effects such as adding window event listeners but they should be cleaned when the component is unmounted.
Composables should only be called in <script setup> or the setup() hook. They should also be called synchronously in these contexts. In some cases, you can also call them in lifecycle hooks like onMounted().
Composables can call other composables inside.
Composables should wrap certain logic inside and when too complex, they should be extracted into separate composables for easier testing.
My recommendations
I have built multiple composables for my work projects and Open Source projects - NuxtAlgolia, NuxtCloudinary, NuxtMedusa, so based on these, I would like to add few points to the contract above that are based on my experience.
Stateful or/and pure functions Composables
At certain point of code standarization, you may come into a conclusion that you would like to make a decision about the state hold in the composables.
The easiest functions to test are those who do not store any state (i.e. they are simple input/output functions), for example a composable that would be responsible for converting bytes to human readable value. It accepts a value and returns a different value - it doesn't store any state.
Don't get me wrong, you don't have to make a decision OR. You can completely keep both stateful and stateless composables. But this should be a written decision so that it is easier to work with them later on 🙂
Unit tests for Composables
We wanted to implement unit tests with Vitest for our Frontend application. When working in the backend, having unit tests code coverage is really useful because there you mainly focus on the logic. However, on the frontend you usually work with visuals.
Because of that, we decided that unit testing whole components may not be the best idea because we will be basically unit testing the framework itself (if a button was pressed, check if a state changed or modal opened).
Thanks to the fact that we have moved all the business logic inside the composables (which are basically TypeScript functions) they are very easy to test with Vitest and allows us also to have more stable system.
Scope of Composables
Some time ago, in VueStorefront we have developed our own approach to composables (way before they were actually called like that actually 😄). In our approach, we have beed using composables to map business domain of E-Commerce like following:
const { cart, load, addItem, removeItem, remove, ... } = useCart()
This approach was definitely useful as it allowed to wrap the domain in one function. And in the simpler examples such as useProduct or useCategory this was relatively simple to implement and maintain. However, as you can see here with the example of useCart when wrapping a domain that contains much more logic than just data fetching, this composable was growing into a shape that was really difficult to develop and maintain. At this point, I started contributing into Nuxt ecosystem where different approach was introduced. In this new approach, each composable is responsible for one thing only. So instead of building a huge useCart composable, the idea is to build composables for each functionality i.e. useAddToCart, useFetchCart, useRemovefromCart, etc. Thanks to that, it should be much easier to maintain and test these composables.
Further reading
That will be all from my research. If you would like to learn more about this topic, make sure to check out the following articles:
Comentários