eleva

Migrating from Vue

Migration Guide For Vue developers transitioning to Eleva.

This guide helps Vue developers understand Eleva by mapping familiar Vue concepts to their Eleva equivalents.


TL;DR - Quick Reference

Vue 3 Eleva Notes
ref(initial) signal(initial) Nearly identical API
reactive(obj) signal(obj) Use signal for objects too
computed(() => val) Function in setup Called during render
watch(source, fn) signal.watch(fn) On signal directly
onMounted(() => {}) onMount hook Returned from setup
v-if="condition" ${cond ? '...' : ''} Ternary in template
v-for="item in items" ${items.map(...).join('')} Array map
v-model="value" value + @input Two-way binding
@click="handler" @click="handler" Same syntax!
:prop="value" :prop="value" Same syntax!
<slot /> children property Different approach

Core Concepts

Reactivity: ref/reactive → signal

Vue:

<script setup>
import { ref, reactive } from 'vue';

const count = ref(0);
const user = reactive({
  name: 'John',
  email: 'john@example.com'
});

function increment() {
  count.value++;
}

function updateName(name) {
  user.name = name;
}
</script>

<template>
  <div>
    <p>Count: 8</p>
    <p>User: </p>
    <button @click="increment">+</button>
  </div>
</template>

Eleva:

const Counter = {
  setup({ signal }) {
    const count = signal(0);
    const user = signal({
      name: 'John',
      email: 'john@example.com'
    });

    const increment = () => count.value++;

    const updateName = (name) => {
      user.value = { ...user.value, name };
    };

    return { count, user, increment, updateName };
  },
  template: (ctx) => `
    <div>
      <p>Count: ${ctx.count.value}</p>
      <p>User: ${ctx.user.value.name}</p>
      <button @click="increment">+</button>
    </div>
  `
};

Key differences:


Computed Properties

Vue:

<script setup>
import { ref, computed } from 'vue';

const items = ref([
  { name: 'Apple', price: 1.00 },
  { name: 'Banana', price: 0.50 }
]);

const total = computed(() => {
  return items.value.reduce((sum, item) => sum + item.price, 0);
});

const expensiveItems = computed(() => {
  return items.value.filter(item => item.price > 0.75);
});
</script>

<template>
  <p>Total: $</p>
  <ul>
    <li v-for="item in expensiveItems"></li>
  </ul>
</template>

Eleva:

const ItemList = {
  setup({ signal }) {
    const items = signal([
      { name: 'Apple', price: 1.00 },
      { name: 'Banana', price: 0.50 }
    ]);

    // Computed values are just functions
    const total = () => {
      return items.value.reduce((sum, item) => sum + item.price, 0);
    };

    const expensiveItems = () => {
      return items.value.filter(item => item.price > 0.75);
    };

    return { items, total, expensiveItems };
  },
  template: (ctx) => `
    <p>Total: $${ctx.total()}</p>
    <ul>
      ${ctx.expensiveItems().map(item => `
        <li key="${item.name}">${item.name}</li>
      `).join('')}
    </ul>
  `
};

Key differences:


Watchers: watch → signal.watch

Vue:

<script setup>
import { ref, watch, watchEffect } from 'vue';

const searchQuery = ref('');
const results = ref([]);

// Watch specific source
watch(searchQuery, async (newQuery, oldQuery) => {
  if (newQuery.length > 2) {
    results.value = await search(newQuery);
  }
});

// Watch effect (auto-tracks dependencies)
watchEffect(() => {
  console.log('Query is:', searchQuery.value);
});
</script>

Eleva:

const SearchComponent = {
  setup({ signal }) {
    const searchQuery = signal('');
    const results = signal([]);

    // Watch specific signal
    searchQuery.watch(async (newQuery) => {
      if (newQuery.length > 2) {
        results.value = await search(newQuery);
      }
    });

    // For watchEffect-like behavior, watch each signal
    searchQuery.watch((query) => {
      console.log('Query is:', query);
    });

    return { searchQuery, results };
  },
  template: (ctx) => `
    <input
      value="${ctx.searchQuery.value}"
      @input="(e) => searchQuery.value = e.target.value"
    />
    <ul>
      ${ctx.results.value.map(r => `<li key="${r.id}">${r.name}</li>`).join('')}
    </ul>
  `
};

Key differences:


Lifecycle Hooks

Vue:

<script setup>
import { onMounted, onUnmounted, onBeforeUpdate } from 'vue';

onMounted(() => {
  console.log('Component mounted');
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  console.log('Component unmounted');
  window.removeEventListener('resize', handleResize);
});

onBeforeUpdate(() => {
  console.log('About to update');
});
</script>

Eleva:

const MyComponent = {
  setup({ signal }) {
    function handleResize() {
      console.log('Window resized');
    }

    return {
      // onMounted equivalent - runs after DOM mount
      onMount: ({ container, context }) => {
        console.log('Component mounted');
        window.addEventListener('resize', handleResize);
      },
      // onUnmounted equivalent - cleanup
      onUnmount: () => {
        console.log('Component unmounted');
        window.removeEventListener('resize', handleResize);
      },
      onBeforeUpdate: ({ container, context }) => {
        console.log('About to update');
      },
      onUpdate: ({ container, context }) => {
        console.log('Updated');
      }
    };
  },
  template: () => `<div>Content</div>`
};

Key differences:


Props

Vue:

<!-- ParentComponent.vue -->
<template>
  <ChildComponent :user="currentUser" :is-admin="true" @update="handleUpdate" />
</template>

<!-- ChildComponent.vue -->
<script setup>
const props = defineProps({
  user: Object,
  isAdmin: Boolean
});

const emit = defineEmits(['update']);

function save() {
  emit('update', props.user);
}
</script>

<template>
  <div>
    <h1></h1>
    <span v-if="props.isAdmin">Admin</span>
  </div>
</template>

Eleva:

// Parent
app.component("ParentComponent", {
  setup({ signal }) {
    const currentUser = signal({ name: "John" });

    const handleUpdate = (user) => {
      console.log("Updated:", user);
    };

    return { currentUser, handleUpdate };
  },
  template: (ctx) => `
    <div
      class="child-container"
      :user="currentUser.value"
      :isAdmin="true"
      :onUpdate="handleUpdate"
    ></div>
  `,
  children: {
    ".child-container": "ChildComponent"
  }
});

// Child
app.component("ChildComponent", {
  setup({ props }) {
    const save = () => {
      // Call parent's handler (parent passed values via .value, so props are plain)
      props.onUpdate(props.user);
    };

    return {
      user: props.user,
      isAdmin: props.isAdmin,
      save
    };
  },
  template: (ctx) => `
    <div>
      <h1>${ctx.user.name}</h1>
      ${ctx.isAdmin ? '<span>Admin</span>' : ''}
    </div>
  `
});

Key differences:


Directives → Template Expressions

Vue:

<template>
  <!-- v-if -->
  <div v-if="isVisible">Visible</div>
  <div v-else-if="isPartial">Partial</div>
  <div v-else>Hidden</div>

  <!-- v-for -->
  <ul>
    <li v-for="item in items" :key="item.id"></li>
  </ul>

  <!-- v-show -->
  <div v-show="isActive">Active content</div>

  <!-- v-model -->
  <input v-model="message" />

  <!-- v-bind -->
  <img :src="imageUrl" :alt="imageAlt" />

  <!-- v-on -->
  <button @click="handleClick" @mouseenter="handleHover">Click</button>
</template>

Eleva:

template: (ctx) => `
  <!-- v-if equivalent -->
  ${ctx.isVisible.value
    ? '<div>Visible</div>'
    : ctx.isPartial.value
      ? '<div>Partial</div>'
      : '<div>Hidden</div>'
  }

  <!-- v-for equivalent -->
  <ul>
    ${ctx.items.value.map(item => `
      <li>${item.name}</li>
    `).join('')}
  </ul>

  <!-- v-show equivalent -->
  <div style="${ctx.isActive.value ? '' : 'display: none;'}">
    Active content
  </div>

  <!-- v-model equivalent -->
  <input
    value="${ctx.message.value}"
    @input="(e) => message.value = e.target.value"
  />

  <!-- v-bind equivalent -->
  <img src="${ctx.imageUrl.value}" alt="${ctx.imageAlt.value}" />

  <!-- v-on equivalent (same syntax!) -->
  <button @click="handleClick" @mouseenter="handleHover">Click</button>
`

Slots → Children Pattern

Vue:

<!-- Card.vue -->
<template>
  <div class="card">
    <header v-if="$slots.header">
      <slot name="header" />
    </header>
    <main>
      <slot />
    </main>
    <footer v-if="$slots.footer">
      <slot name="footer" />
    </footer>
  </div>
</template>

<!-- Usage -->
<Card>
  <template #header>
    <h1>Title</h1>
  </template>

  <p>Main content</p>

  <template #footer>
    <button>Action</button>
  </template>
</Card>

Eleva:

// Eleva uses a different pattern - compose in parent
app.component("Card", {
  setup({ props }) {
    return {
      header: props.header,
      content: props.content,
      footer: props.footer
    };
  },
  template: (ctx) => `
    <div class="card">
      ${ctx.header ? `<header>${ctx.header}</header>` : ''}
      <main>${ctx.content}</main>
      ${ctx.footer ? `<footer>${ctx.footer}</footer>` : ''}
    </div>
  `
});

// Usage - pass content as props
app.component("Page", {
  template: () => `
    <div
      class="card-container"
      :header="'<h1>Title</h1>'"
      :content="'<p>Main content</p>'"
      :footer="'<button>Action</button>'"
    ></div>
  `,
  children: {
    ".card-container": "Card"
  }
});

Alternative: Child components

app.component("CardWithChildren", {
  template: () => `
    <div class="card">
      <header class="card-header"></header>
      <main class="card-content"></main>
      <footer class="card-footer"></footer>
    </div>
  `,
  children: {
    ".card-header": "CardHeader",
    ".card-content": "CardContent",
    ".card-footer": "CardFooter"
  }
});

Vue Router → Eleva Router

Vue Router:

// router.js
import { createRouter, createWebHistory } from 'vue-router';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: Home },
    { path: '/users/:id', component: UserProfile },
    { path: '/:pathMatch(.*)*', component: NotFound }
  ]
});

router.beforeEach((to, from) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return '/login';
  }
});

// In component
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();
const router = useRouter();

console.log(route.params.id);
router.push('/users/123');

Eleva Router:

import Eleva from "eleva";
import { Router } from "eleva/plugins";

const app = new Eleva("App");

const router = app.use(Router, {
  mode: "history",  // or "hash"
  mount: "#app",
  routes: [
    { path: "/", component: Home },
    { path: "/users/:id", component: UserProfile },
    { path: "*", component: NotFound }
  ]
});

// Navigation guard
router.onBeforeEach((to, from) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return "/login";
  }
});

// In component
const UserProfile = {
  setup({ router }) {
    const userId = router.params.id;

    // Navigate programmatically
    const goHome = () => router.navigate("/");

    return { userId, goHome };
  },
  template: (ctx) => `
    <h1>User ${ctx.userId}</h1>
    <button @click="goHome">Home</button>
  `
};

Vuex/Pinia → Eleva Store

Pinia:

// stores/counter.js
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    user: null
  }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() {
      this.count++;
    },
    async fetchUser(id) {
      this.user = await api.getUser(id);
    }
  }
});

// In component
import { useCounterStore } from '@/stores/counter';

const store = useCounterStore();
store.increment();
console.log(store.doubleCount);

Eleva Store:

import Eleva from "eleva";
import { Store } from "eleva/plugins";

const app = new Eleva("App");

app.use(Store, {
  state: {
    count: 0,
    user: null
  },
  actions: {
    increment: (state) => state.count.value++,
    fetchUser: async (state, id) => {
      state.user.value = await api.getUser(id);
    }
  }
});

// In component
const Counter = {
  setup({ store }) {
    // Computed equivalent
    const doubleCount = () => store.state.count.value * 2;

    return {
      count: store.state.count,
      doubleCount,
      increment: () => store.dispatch("increment"),
      fetchUser: (id) => store.dispatch("fetchUser", id)
    };
  },
  template: (ctx) => `
    <p>Count: ${ctx.count.value}</p>
    <p>Double: ${ctx.doubleCount()}</p>
    <button @click="increment">+</button>
  `
};

Single File Components → Component Objects

Vue SFC:

<script setup>
import { ref } from 'vue';

const count = ref(0);
const increment = () => count.value++;
</script>

<template>
  <button @click="increment">Count: 8</button>
</template>

<style scoped>
button {
  background: blue;
  color: white;
}
</style>

Eleva Component:

// counter.js
export const Counter = {
  setup({ signal }) {
    const count = signal(0);
    const increment = () => count.value++;

    return { count, increment };
  },
  template: (ctx) => `
    <button class="counter-btn" @click="increment">
      Count: ${ctx.count.value}
    </button>
  `,
  // Styles - add to your CSS file or use inline
  // <style> .counter-btn { background: blue; color: white; } </style>
};

// Or with inline styles
export const CounterStyled = {
  setup({ signal }) {
    const count = signal(0);
    return { count, increment: () => count.value++ };
  },
  template: (ctx) => `
    <button
      style="background: blue; color: white;"
      @click="increment"
    >
      Count: ${ctx.count.value}
    </button>
  `
};

Common Migration Patterns

Two-Way Binding (v-model)

<!-- Vue -->
<input v-model="message" />
<input v-model.number="count" />
<input v-model.trim="name" />
// Eleva
template: (ctx) => `
  <input
    value="${ctx.message.value}"
    @input="(e) => message.value = e.target.value"
  />
  <input
    type="number"
    value="${ctx.count.value}"
    @input="(e) => count.value = Number(e.target.value)"
  />
  <input
    value="${ctx.name.value}"
    @input="(e) => name.value = e.target.value.trim()"
  />
`

Class and Style Bindings

<!-- Vue -->
<div :class="{ active: isActive, 'text-danger': hasError }"></div>
<div :class="[activeClass, errorClass]"></div>
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
// Eleva
template: (ctx) => `
  <div class="${ctx.isActive.value ? 'active' : ''} ${ctx.hasError.value ? 'text-danger' : ''}"></div>
  <div class="${ctx.activeClass.value} ${ctx.errorClass.value}"></div>
  <div style="color: ${ctx.activeColor.value}; font-size: ${ctx.fontSize.value}px;"></div>
`

Event Modifiers

<!-- Vue -->
<form @submit.prevent="onSubmit"></form>
<button @click.stop="doThis"></button>
<input @keyup.enter="submit" />
// Eleva - handle in the function
template: (ctx) => `
  <form @submit="(e) => { e.preventDefault(); onSubmit(); }"></form>
  <button @click="(e) => { e.stopPropagation(); doThis(); }"></button>
  <input @keyup="(e) => { if (e.key === 'Enter') submit(); }" />
`

What You Gain

Smaller Bundle

Simpler Mental Model

Familiar Syntax

Better Performance


Migration Checklist


See Also


← From React Back to Migration Overview From Alpine.js →