Vue 3 plugins: write your own with install and provide

Vue 3 plugins: write your own with install and provide
Quick answer

A Vue 3 plugin is an object with an install(app, options) method, installed with app.use(). It registers components and directives, supplies values through app.provide() and configures the application. Vue 2 code built on Vue.prototype and Vue.mixin no longer works.

A Vue plugin adds capabilities to an entire application: a component available everywhere, a directive, an injectable value, shared configuration. It is the mechanism behind Vue Router, Pinia and most of the libraries in the ecosystem.

What a plugin is

An object with an install method, or a plain function. Vue calls it with the application instance and the options passed to app.use().

src/plugins/monitor.js
export default {
  install(app, options) {
    // everything happens here
  }
}
Vue 2 code no longer runs

In Vue 2, install received the global Vue constructor, and a plugin worked on Vue.prototype, Vue.mixin or Vue.component. In Vue 3 there is no global constructor any more: install receives the instance created by createApp, and everything goes through it. A Vue 2 plugin copied across as-is fails with Cannot set properties of undefined (setting '$api'), or crashes silently.

Here is the mapping:

javascript
// Vue 3
Vue.use(Plugin)                   app.use(Plugin)
Vue.component('X', X)             app.component('X', X)
Vue.directive('x', x)             app.directive('x', x)
Vue.mixin({ … })                  app.mixin({ … })
Vue.prototype.$api = api          app.config.globalProperties.$api = api
                                  app.provide('api', api)   // preferred

A complete plugin

The plugin below logs component mounts, exposes that log and provides a directive. It covers the four hooks worth knowing.

src/plugins/monitor.js
export default {
  install(app, options = {}) {
    const prefixe = options.prefixe ?? '[monitor]'
    const journal = []

    // 1. global property, reachable through this in the Options API
    app.config.globalProperties.$journal = journal

    // 2. injection: the recommended route with <script setup>
    app.provide('journal', journal)

    // 3. global directive
    app.directive('surligne', {
      mounted(el, binding) {
        el.style.backgroundColor = binding.value ?? '#fef08a'
      }
    })

    // 4. global mixin, run when each component is mounted
    app.mixin({
      mounted() {
        journal.push(`${prefixe} ${this.$options.__name ?? 'anonyme'} monté`)
      }
    })
  }
}

Installing it, with or without options:

src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import monitor from './plugins/monitor.js'

const app = createApp(App)

app.use(monitor, { prefixe: '[gekkode]' })
app.mount('#app')

A plugin installed twice is only applied once: Vue keeps a register of the plugins already passed to app.use().

provide rather than globalProperties

app.config.globalProperties.$journal reproduces the Vue 2 habit of this.$something. It works, but this does not exist in <script setup>: the property is only reachable from the template or the Options API. It also escapes autocompletion and typing.

app.provide() is the right route. The component picks the value up with inject:

src/components/Journal.vue
<script setup>
import { inject } from 'vue'

const journal = inject('journal')
</script>

<template>
  <p>{{ journal.length }} composants montés</p>
</template>

To avoid collisions between string names, use a Symbol exported by the plugin:

src/plugins/monitor.js
export const cleJournal = Symbol('journal')

export default {
  install(app) {
    app.provide(cleJournal, [])
  }
}

Shipping components

The most common case: making a library of components available without an import.

src/plugins/ui.js
import GkBouton from '../components/GkBouton.vue'
import GkCarte from '../components/GkCarte.vue'

export default {
  install(app, { prefixe = 'Gk' } = {}) {
    app.component(`${prefixe}Bouton`, GkBouton)
    app.component(`${prefixe}Carte`, GkCarte)
  }
}

Mind the trade-off: a globally registered component is available everywhere, but it escapes tree shaking. It ends up in the bundle even if nobody uses it. Keep global registration for the components that really are everywhere.

A plugin can be a plain function

If an object with an install method feels ceremonious, a function will do: Vue calls it with the same arguments.

src/plugins/titre.js
export default function titrePlugin(app, options) {
  app.config.globalProperties.$titre = options.titre
}

The publishable package

Test it first in a fresh Vite project. To ship it on npm, export it as the default and keep the Vue dependency in peerDependencies, so that the host project supplies its own copy:

package.json
{
  "name": "gekkode-monitor",
  "type": "module",
  "main": "./dist/index.js",
  "exports": { ".": "./dist/index.js" },
  "peerDependencies": { "vue": "^3.5.0" }
}
Auto-installation has no purpose left

Vue 2 plugins often ended with if (window.Vue) window.Vue.use(Plugin), so they could install themselves when the page loaded Vue through a script tag. There is no global window.Vue in Vue 3: that block does nothing and should be removed.

The global mixin, as a last resort

The example plugin uses app.mixin to watch every mount. That is the right tool for a cross-cutting need such as telemetry, where you genuinely want to reach every component.

For everything else, prefer a composable: an exported function that the interested components call. A global mixin applies everywhere, including where nobody expects it, and makes the origin of a behaviour hard to track down.

src/composables/useJournal.js
import { inject } from 'vue'

export function useJournal() {
  const journal = inject('journal')
  return {
    journal,
    ajouter: (message) => journal.push(message)
  }
}

Testing a plugin

Vue Test Utils takes plugins in the global option, along with their options:

javascript
import { mount } from '@vue/test-utils'
import monitor from '@/plugins/monitor.js'

mount(MonComposant, {
  global: { plugins: [[monitor, { prefixe: '[test]' }]] }
})

Without options, the short form is enough: plugins: [monitor].

Going further

Plenty of libraries in the ecosystem install this way, from VueFire to unhead for search engine optimisation.

Plugins make full sense once components and injection are familiar. If those notions are still new, go back to the Vue 3 tutorial for beginners, in particular the chapters on components and props. For shared state, Pinia is itself a plugin, and its source is worth reading.

Common errors

Copying a Vue 2 plugin across install receives the application instance, not the global constructor. Vue.prototype is gone: use app.provide() or app.config.globalProperties.
globalProperties with script setup this does not exist in <script setup>: the property is only readable from the template. Prefer provide and inject.
Keeping the auto-installation block if (window.Vue) window.Vue.use(Plugin) has nothing left to find in Vue 3 and should be removed.
Registering too many components globally They escape tree shaking and weigh the bundle down even when unused.

JavaScriptVue.js

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.