Composable Design Patterns in Vue

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:

  • Patterns for better state management
  • Organizing your composables better (you don’t always need a separate file!)
  • Improving developer experience, like supporting both async and sync behaviour

In this article, we’ll cover seven different patterns on writing better composables.

1. The Data Store Pattern

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:

  1. A global state singleton
  2. Exporting some or all of this state
  3. Methods to access and modify the state

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 composable
const state = reactive({
darkMode: false,
sidebarCollapsed: false,
// 2. This theme value is kept private to this composable
theme: 'nord',
});
export default () => {
// 2. Expose only some of the state
// Using toRefs allows us to share individual values
const { darkMode, sidebarCollapsed } = toRefs(state);
// 3. Modify our underlying state
const changeTheme = (newTheme) => {
if (themes.includes(newTheme)) {
// Only update if it's a valid theme
state.theme = newTheme;
}
}
return {
// 2. Only return some of the state
darkMode,
sidebarCollapsed,
// 2. Only expose a readonly version of state
theme: readonly(state.theme),
// 3. We return a method to modify underlying state
changeTheme,
}
}

2. Thin Composables

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 function
fahrenheit.value = convertToFahrenheit(newCelsius);
});
return { fahrenheit };
}

3. Inline Composables

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.

4. Dynamic Return

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 value
const 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 values
const {
counter,
pause,
resume,
} = useInterval(1000, { controls: true });

5. Flexible Arguments

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.

6. Async + Sync

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 immediately
const data = ref(null);
// Regular async fetch
const asyncOperationPromise = ofetch(
'https://api.example.com/data'
)
.then(response => {
// Reactively update the data ref
data.value = response;
return { data };
});
// Enhance the promise with immediate properties
const 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.

7. Options Object

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 recursively
deep: true,
// Limit how many changes we save
capacity: 10,
});

Here’s a high level of the benefits:

  1. Easy to add new options without breaking existing usage
  2. Easier to use — no need to remember parameter orders
  3. It’s self-documenting, and much more readable

Implementing it can be quite straightforward in the simplest case:

export function useRefHistory(ref, options) {
const {
deep = false,
capacity = Infinity,
} = options;
// ...
};

Conclusion

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:

  • In-depth explanations for each pattern — when to use it, edge cases, and variations
  • Real-world examples — so you can see how to use them beyond these simple examples

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.