Writing a singleton for Vue.js composition API

pipoprods

Sébastien NOBILI

Posted on October 21, 2022

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;                                  
};   
Enter fullscreen mode Exit fullscreen mode

In our Vue components, we can operate on them the following way:

<template>
    <button @click="prev()">Prev</button>
    <span>{{ current }}</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>
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
  };
})()
Enter fullscreen mode Exit fullscreen mode

There are important things to notice here:

  • Our closure is executed automatically
export default (function () {

})()  // <-- The JavaScript engine executes it because of the `()`
Enter fullscreen mode Exit fullscreen mode
  • It returns an anonymous function that returns the instance
  return () => {
    return instance;
  };
Enter fullscreen mode Exit fullscreen mode

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;
  };
Enter fullscreen mode Exit fullscreen mode

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;
  };
Enter fullscreen mode Exit fullscreen mode

Then pass the steps from the component to the singleton:

const { current, prev, next } = useOnboarding(['Step 1', 'Step 2', 'Step 3', 'Step 4'])
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
pipoprods
Sébastien NOBILI

Posted on October 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related