Quality and migration

Migrating from Vue

The template syntax was designed to be familiar to anyone coming from Vue: directives, modifiers, v-model and the component model use the same names. The underlying difference is that there is no compilation, Voodoo interprets the HTML that is already on the page. That changes what is possible; it is worth reading the limits section before deciding.

createApp

Mounting has the same shape, and the same root component options:

// Vue
createApp({ data: () => ({ n: 0 }), template: '<b>{{ n }}</b>' }).mount('#app');

// Voodoo.js
V.createApp({ data: () => ({ n: 0 }), template: '<b>{ n }</b>' }).mount('#app');
VueVoodoo.js
app.component(name, def)same
app.directive(name, def)same
app.use(plugin, options)same
app.provide(key, value)same
app.config.globalPropertiessame
app.mount(), app.unmount()same. unmount restores the container's original HTML
app.mixin()does not exist
the mount target must already existit may not exist yet: mounting waits for it

The guide on the two modes explains when to use createApp and when to stay with attributes.

Template

VueVoodoo.jsNote
{{ name }}{ name }The double brace works too
v-text, v-html, v-showsame
v-if, v-else-if, v-elsesame
v-for, in every formsameIncludes (item, i) and (value, key)
:key, v-bind:x, :x, v-bind="object"same
.prop.prop and the . shorthand
v-on:click, @clicksame, plus the v-click shorthand
v-model, v-once, v-cloak, v-presame
v-memodoes not exist
v-slotslot="name" attribute on the child
<Teleport>v-teleport="#target"
<Transition>v-transition="fade"
<KeepAlive>does not exist
<Suspense>does not existUse v-resource with loading

Modifiers

.prevent, .stop, .self, .once, .capture, .passive, the key modifiers and .ctrl, .shift, .alt, .meta are the same. From v-model, so are .lazy, .number and .trim.

These do not exist: .exact and the mouse button modifiers (.left, .right, .middle). These exist only here: .window, .document, .outside, .debounce and .throttle.

Components

Vue (Options API)Voodoo.js
props: { x: { type: Number, default: 0 } }props: { x: { type: 'number', default: 0 } }
props: ['a', 'b']same
data() { return {} }state(props) or data(props)
computed, methods, watch, templatesame
emits: []does not exist
this.$emit('x', d)this.emit('x', d) or this.$emit
this.$refs, $el, $props, $parent, $nextTick()same
this.$watch(source, cb)this.$watch('expression', cb)
provide and injectsame, on the component or on the app definition
<slot>, <slot name="x">same
mounted, updated, beforeUnmount, unmountedsame
created, beforeCreateonly beforeMount
<style scoped>style: '...', with no scope isolation
// Vue
app.component('user-card', { props: ['name'], template: '<p>{{ name }}</p>' });

// Voodoo.js
V.component('user-card', { props: ['name'], template: '<p>{ name }</p>' });

Both tag spellings work, as in Vue with compilation: <user-card> and <UserCard>.

Reactivity

reactive, ref, shallowRef, computed (also in the { get, set } form), watch, watchEffect, effectScope, nextTick, toRaw, markRaw, unref and isReactive exist under the same names, on V. The watch options are the same: immediate, deep and flush with pre, post and sync.

Two absences that catch people out

There is no readonly, shallowReactive, toRefs or customRef. And there is no automatic ref unwrapping in the template: inside the expression, use .value.

Global state

VueVoodoo.js
Pinia: defineStore('cart', { state, getters, actions })V.store('cart', { ...state, ...methods })
useCart()V.store('cart')
in the template: cart.items$store.cart.items
provide and injectsame, or a store for shared state

Pinia getters become methods, because a get does not track changes:

V.store('cart', {
  items: [],
  total() { return this.items.reduce((s, i) => s + i.price, 0) }
});

Router

Vue RouterVoodoo.js
createRouter({ history, routes })V.router({ mode, routes })
path: '/x', component: X'/x': { component: 'x' }
<router-view><main v-router-view>
<router-link to="/x"><a v-link href="/x">
router.push('/x')V.router.push('/x') or V.navigate('/x')
useRoute()V.route, and $route in the HTML
beforeEach, afterEach, beforeEntersame
nested routesdo not exist
component lazy loadinga route with view, which fetches remote HTML

Internationalization

createI18n({ locale, messages }) becomes V.i18n({ locale, messages }); $t('key') and pluralization with a vertical bar work the same way; $n and $d exist, plus $c and $rt; and locale.value = 'en' becomes V.setLocale('en').

Transitions

<!-- Vue -->
<Transition name="fade"><p v-if="visible">Hello</p></Transition>

<!-- Voodoo.js -->
<p v-if="visible" v-transition="fade">Hello</p>

The classes follow the same pattern: v-fade-enter-from, v-fade-enter-active, v-fade-enter-to, and the three matching leave classes.

What does not exist, and why

Voodoo interprets the page's HTML. Without compilation, some things from Vue have no way to exist:

Expressions also accept only a subset of JavaScript: no function, class, new, delete, await, loops or destructuring. The expression reference lists what is allowed.

What you gain

When not to switch

If your project has dozens of screens, a large team, component tests and strict typing in the templates, Vue is still the right choice. Voodoo was made for the other side of the scale: server-generated pages, admin panels, prototypes and small teams.

Coexisting

The two can run on the same page, in different areas. Mark the Vue region with v-ignore so that Voodoo does not touch it:

<div id="app-vue" v-ignore></div>

<div v-data="{ n: 0 }">
  <button @click="n++">{ n }</button>
</div>