You’ve already mastered the basics of writing composables in Vue.
Your next step is to collect a bunch of the best and most useful patterns, expanding your problem-solving toolkit:
In this article, we’ll cover seven different patterns on writing better composables.
The simplest solution to lots of state management problems is to use a composable to create a shareable data store.
This pattern has a few parts:
Here's a simple example:
import { reactive, toRefs, readonly } from 'vue';import { themes } from './utils';// 1. Create global state in module scope, shared every// time we use this composableconst state = reactive({darkMode: false,sidebarCollapsed: false,// 2. This theme value is kept private to this composabletheme: 'nord',});export default () => {// 2. Expose only some of the state// Using toRefs allows us to share individual valuesconst { darkMode, sidebarCollapsed } = toRefs(state);// 3. Modify our underlying stateconst changeTheme = (newTheme) => {if (themes.includes(newTheme)) {// Only update if it's a valid themestate.theme = newTheme;}}return {// 2. Only return some of the statedarkMode,sidebarCollapsed,// 2. Only expose a readonly version of statetheme: readonly(state.theme),// 3. We return a method to modify underlying statechangeTheme,}}
Thin composables introduce an additional layer of abstraction, separating the reactivity management from the core business logic. Here we use plain JavaScript or TypeScript for business logic, represented as pure functions, with a thin layer of reactivity on top.
import { ref, watch } from 'vue';import { convertToFahrenheit } from './temperatureConversion';export function useTemperatureConverter(celsiusRef: Ref<number>) {const fahrenheit = ref(0);watch(celsiusRef, (newCelsius) => {// Actual logic is contained within a pure functionfahrenheit.value = convertToFahrenheit(newCelsius);});return { fahrenheit };}
We don’t always have to extract logic into a separate file.
Instead, we can create inline composables that live in the single file component, just like any other function would:
const useCount = (i) => {const count = ref(0);const increment = () => count.value += 1;const decrement = () => count.value -= 1;return {id: i,count,increment,decrement,};};const listOfCounters = [];for (const i = 0; i < 10; i++) {listOfCounters.push(useCount(i));}
In our template we can use the counters individually:
<div v-for="counter in listOfCounters" :key="counter.id"><button @click="counter.decrement()">-</button>{{ counter.count }}<button @click="counter.increment()">+</button></div>
You can read more about inline composables here.
In many cases, when we use composables, we only need them to return a single value. This approach keeps our code clean and the API straightforward.
For example:
// Returns a single valueconst isDark = useDark();
However, there are situations where we require more control and need additional values or methods from the composable.
In these cases, returning an object of values can provide the necessary flexibility:
// Returns an object of valuesconst {counter,pause,resume,} = useInterval(1000, { controls: true });
When our composable needs to work with reactive data, we can use Vue's ref
function to make sure we always have a ref
, regardless of the input type:
export function useMyComposable(input) {const valueRef = ref(input);// Now, valueRef is always a *ref*,// whether input was a ref or a raw value}
If we pass a ref
, we get that same ref
back.
And if we pass a raw value, it gets wrapped in a ref
.
When our composable needs a raw value, we can use Vue's toValue
function to extract the value from a ref or getter:
export function useMyComposable(input) {const value = toValue(input)// Now, value is always the *raw* value,// whether input was a ref, getter or raw value}
If we pass a ref or getter, toValue
unwraps it to give us the raw value.
And if we pass a raw value, it simply returns that value.
Sometimes, it’s nice to be able to use a composable either asynchronously or synchronously, like useAsyncData
in Nuxt.
Here’s a basic outline of how you can accomplish that:
import { ref } from 'vue';import { ofetch } from 'ofetch';function useAsyncOrSync() {// Whatever you set this to will be returned immediatelyconst data = ref(null);// Regular async fetchconst asyncOperationPromise = ofetch('https://api.example.com/data').then(response => {// Reactively update the data refdata.value = response;return { data };});// Enhance the promise with immediate propertiesconst enhancedPromise = Object.assign(asyncOperationPromise, {data,});return enhancedPromise;}
If we use it synchronously, we immediately get back the value that we initialized data
to. Then, when the Promise finally resolves, it is updated reactively.
Or, just await
the Promise itself, and you won’t have to deal with null
values at all.
You can read more about this pattern here.
If we need to pass a bunch of different options to a composable in order to make it more reusable, using arguments like this gets old pretty fast:
const { history, undo, redo } = useRefHistory(state, true, 10));
Instead, we can use an options object, which gives us a whole host of benefits:
const { history, undo, redo } = useRefHistory(state, {// Track history recursivelydeep: true,// Limit how many changes we savecapacity: 10,});
Here’s a high level of the benefits:
Implementing it can be quite straightforward in the simplest case:
export function useRefHistory(ref, options) {const {deep = false,capacity = Infinity,} = options;// ...};
These are just a few of the composable patterns that I’ve collected over the years. And we’re only scratching the surface of how to use them, edge cases you’ll encounter.
I’ve got many more patterns, and I’m compiling them all into an in-depth course on composable patterns. It will have:
Go here to sign up for more details, and to get a discount when it launches!
If you want to learn some component design patterns, I’ve got a list of twelve of them over here.