Writing a singleton for Vue.js composition API
Vue.js composition API gives developers the possibility to extract parts of components into dedicated modules. This approach can lead to cleaner and lighter components, more structured code and a simplified way to reuse code.
When a component becomes huge, it's quite simple to move parts of it into a module that will expose them to the outside world.
But how could we deal with a shared context between instances of the module? This is where singletons can be an answer.
Expectations #
To illustrate this, we'll go through the implementation of an onboarding feature:
- there is a list of messages to display
- a single message should be displayed at a given time
- we can cycle forwards & backwards through messages
- the onboarding can be globally shown/hidden
- the onboarding popups will be embedded in different components of the application
Implementation #
Our singleton will expose these elements:
type OnboardingInstance = {
show: Ref<boolean>;
current: Ref<string>;
prev: () => void;
next: () => void;
};
In our Vue components, we can operate on them the following way:
<template>
<button @click="prev()">Prev</button>
<span></span>
<button @click="next()">Next</button>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import useOnboarding from './onboarding.ts';
export default defineComponent({
setup(props) {
const { current, prev, next } = useOnboarding();
return { current, prev, next };
},
});
</script>
First implementation #
The code below implements the properties & methods that our module should expose. This is a simple Vue.js TypeScript module.
import { computed, ref } from 'vue';
let step = ref(0);
let _steps: string[] = ['Step 1', 'Step 2', 'Step 3', 'Step 4'];
function prev() {
if (step.value > 0) step.value--;
}
function next() {
if (step.value < _steps.length - 1) step.value++;
}
const current = computed(() => _steps[step.value]);
return { show, current, prev, next };
There's no singleton here. Each component of our application will get its own instance of the module.
Making it a singleton #
To make this module become a singleton, let's wrap its code into a closure.
export default (function () {
// Paste the code of the first implementation here
// (except the `return` statement of course)
let instance: OnboardingInstance = { show, current, prev, next };
return () => {
return instance;
};
})();
There are important things to notice here:
- Our closure is executed automatically
export default (function () {})(); // <-- The JavaScript engine executes it because of the `()`
- It returns an anonymous function that returns the instance
return () => {
return instance;
};
Making it able to get arguments #
Our module is now a singleton. Steps & state are shared across all the components that load it. But steps are hard-coded into the module. Let's fix this now.
The anonymous function that we return is the one that will get the arguments from the outside world:
return () => {
return instance;
};
To be able to declare the steps from the outside, we can change it this way:
return (steps?: string[]) => {
if (steps) {
_steps = steps;
}
return instance;
};
Then pass the steps from the component to the singleton:
const { current, prev, next } = useOnboarding([
'Step 1',
'Step 2',
'Step 3',
'Step 4',
]);
Final thoughts #
The main difficulty of this pattern is with preserving Vue.js reactivity.
This implementation preserves Vue.js reactivity provided you get its props & methods through destructuring assignment.
The use of the instance as a single object with preserved reactivity is possible through the reactive method of Vue.js:
const onboarding = reactive(useOnboarding());
This singleton implementation gives the possibility to maintain a shared state and have multiple components subscribe to its data and render it. This way the components code itself can be lighter and easier to deal with.
Let's discuss about this on Mastodon!