eleva

Migrating from Alpine.js

Version: 1.0.0 A guide for Alpine.js developers transitioning to Eleva

This guide helps Alpine.js developers understand Eleva by mapping familiar Alpine concepts to their Eleva equivalents. Both frameworks share a similar philosophy—lightweight, no build step, progressive enhancement—but take different approaches: Alpine is HTML-first with directives, while Eleva is JS-first with template functions.


TL;DR - Quick Reference

Alpine.js Eleva Notes
x-data="{ count: 0 }" setup({ signal }) Component state
x-text="message" ${ctx.message.value} Text interpolation
x-html="content" ${ctx.content.value} HTML content
x-show="isVisible" style="display: ${...}" Toggle visibility
x-if="condition" ${cond ? '...' : ''} Conditional render
x-for="item in items" ${items.map(...).join('')} List rendering
x-on:click="handler" @click="handler" Event handling
x-model="value" value + @input Two-way binding
x-bind:class="..." class="${...}" Attribute binding
x-init="..." Code in setup() Initialization
$watch('prop', fn) signal.watch(fn) Watch changes
$store Store plugin Global state

Philosophy Comparison

Alpine: HTML-First (Declarative)

<!-- Logic lives in HTML attributes -->
<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>
  <div x-show="open">Content</div>
</div>

Eleva: JS-First (Programmatic)

// Logic lives in JavaScript, HTML is generated
const Toggle = {
  setup({ signal }) {
    const open = signal(false);
    return { open, toggle: () => open.value = !open.value };
  },
  template: (ctx) => `
    <div>
      <button @click="toggle">Toggle</button>
      ${ctx.open.value ? '<div>Content</div>' : ''}
    </div>
  `
};

Neither approach is “better”—they serve different preferences:


Core Concepts

State: x-data → setup + signal

Alpine:

<div x-data="{ count: 0, name: 'John' }">
  <p x-text="count"></p>
  <p x-text="name"></p>
  <button @click="count++">Increment</button>
</div>

Eleva:

const Counter = {
  setup({ signal }) {
    const count = signal(0);
    const name = signal('John');

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

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

Key differences:


Text & HTML: x-text/x-html → Template Interpolation

Alpine:

<div x-data="{ message: 'Hello', html: '<strong>Bold</strong>' }">
  <span x-text="message"></span>
  <span x-html="html"></span>
  <p x-text="`Count is ${count}`"></p>
</div>

Eleva:

template: (ctx) => `
  <div>
    <span>${ctx.message.value}</span>
    <span>${ctx.html.value}</span>
    <p>Count is ${ctx.count.value}</p>
  </div>
`

Key differences:


Visibility: x-show → Inline Style

Alpine:

<div x-data="{ open: false }">
  <button @click="open = !open">Toggle</button>
  <div x-show="open">Visible when open</div>
  <div x-show="open" x-transition>With transition</div>
</div>

Eleva:

const Toggle = {
  setup({ signal }) {
    const open = signal(false);
    return { open, toggle: () => open.value = !open.value };
  },
  template: (ctx) => `
    <div>
      <button @click="toggle">Toggle</button>
      <div style="${ctx.open.value ? '' : 'display: none;'}">
        Visible when open
      </div>
      <div
        style="${ctx.open.value ? '' : 'display: none;'}"
        class="${ctx.open.value ? 'fade-in' : 'fade-out'}"
      >
        With transition (use CSS classes)
      </div>
    </div>
  `
};

Key differences:


Conditional Rendering: x-if → Ternary Expressions

Alpine:

<div x-data="{ loggedIn: false, role: 'admin' }">
  <template x-if="loggedIn">
    <div>Welcome back!</div>
  </template>

  <template x-if="role === 'admin'">
    <div>Admin panel</div>
  </template>
  <template x-if="role === 'user'">
    <div>User dashboard</div>
  </template>
</div>

Eleva:

const Dashboard = {
  setup({ signal }) {
    const loggedIn = signal(false);
    const role = signal('admin');
    return { loggedIn, role };
  },
  template: (ctx) => `
    <div>
      ${ctx.loggedIn.value ? `
        <div>Welcome back!</div>
      ` : ''}

      ${ctx.role.value === 'admin' ? `
        <div>Admin panel</div>
      ` : ctx.role.value === 'user' ? `
        <div>User dashboard</div>
      ` : ''}
    </div>
  `
};

Key differences:


List Rendering: x-for → Array.map()

Alpine:

<div x-data="{ items: [
  { id: 1, name: 'Apple' },
  { id: 2, name: 'Banana' }
]}">
  <ul>
    <template x-for="item in items" :key="item.id">
      <li x-text="item.name"></li>
    </template>
  </ul>
</div>

Eleva:

const ItemList = {
  setup({ signal }) {
    const items = signal([
      { id: 1, name: 'Apple' },
      { id: 2, name: 'Banana' }
    ]);
    return { items };
  },
  template: (ctx) => `
    <div>
      <ul>
        ${ctx.items.value.map(item => `
          <li key="${item.id}">${item.name}</li>
        `).join('')}
      </ul>
    </div>
  `
};

Key differences:


Event Handling: x-on/@click → @event

Alpine:

<div x-data="{ count: 0 }">
  <!-- Full syntax -->
  <button x-on:click="count++">+</button>

  <!-- Shorthand -->
  <button @click="count++">+</button>

  <!-- With modifiers -->
  <form @submit.prevent="handleSubmit">
  <button @click.stop="doSomething">Click</button>
  <input @keydown.enter="submit" />
  <div @click.outside="close">Dropdown</div>
</div>

Eleva:

const Events = {
  setup({ signal }) {
    const count = signal(0);

    const handleSubmit = (e) => {
      e.preventDefault();
      // submit logic
    };

    const doSomething = (e) => {
      e.stopPropagation();
      // logic
    };

    const submit = () => { /* submit */ };

    return { count, handleSubmit, doSomething, submit };
  },
  template: (ctx) => `
    <div>
      <button @click="() => count.value++">+</button>
      <button @click="() => count.value++">+</button>

      <form @submit="handleSubmit">
      <button @click="doSomething">Click</button>
      <input @keydown="(e) => e.key === 'Enter' && submit()" />
      <!-- For click.outside, implement custom logic -->
    </div>
  `
};

Key differences:


Two-Way Binding: x-model → value + @input

Alpine:

<div x-data="{ text: '', checked: false, selected: 'a' }">
  <input type="text" x-model="text" />
  <input type="checkbox" x-model="checked" />
  <select x-model="selected">
    <option value="a">A</option>
    <option value="b">B</option>
  </select>

  <!-- With modifiers -->
  <input x-model.number="count" />
  <input x-model.debounce.500ms="search" />
</div>

Eleva:

const Form = {
  setup({ signal }) {
    const text = signal('');
    const checked = signal(false);
    const selected = signal('a');
    const count = signal(0);
    const search = signal('');

    // Debounce helper
    let timeout;
    const debouncedSearch = (value) => {
      clearTimeout(timeout);
      timeout = setTimeout(() => search.value = value, 500);
    };

    return { text, checked, selected, count, search, debouncedSearch };
  },
  template: (ctx) => `
    <div>
      <input
        type="text"
        value="${ctx.text.value}"
        @input="(e) => text.value = e.target.value"
      />
      <input
        type="checkbox"
        ${ctx.checked.value ? 'checked' : ''}
        @change="(e) => checked.value = e.target.checked"
      />
      <select @change="(e) => selected.value = e.target.value">
        <option value="a" ${ctx.selected.value === 'a' ? 'selected' : ''}>A</option>
        <option value="b" ${ctx.selected.value === 'b' ? 'selected' : ''}>B</option>
      </select>

      <input
        type="number"
        value="${ctx.count.value}"
        @input="(e) => count.value = Number(e.target.value)"
      />
      <input
        value="${ctx.search.value}"
        @input="(e) => debouncedSearch(e.target.value)"
      />
    </div>
  `
};

Key differences:


Attribute Binding: x-bind → Template Interpolation

Alpine:

<div x-data="{ imageUrl: '/img.jpg', isActive: true }">
  <img x-bind:src="imageUrl" />
  <img :src="imageUrl" />

  <div :class="{ active: isActive, 'text-red': hasError }"></div>
  <div :class="isActive ? 'active' : 'inactive'"></div>

  <div :style="{ color: textColor, fontSize: size + 'px' }"></div>
</div>

Eleva:

template: (ctx) => `
  <div>
    <img src="${ctx.imageUrl.value}" />
    <img src="${ctx.imageUrl.value}" />

    <div class="${ctx.isActive.value ? 'active' : ''} ${ctx.hasError.value ? 'text-red' : ''}"></div>
    <div class="${ctx.isActive.value ? 'active' : 'inactive'}"></div>

    <div style="color: ${ctx.textColor.value}; font-size: ${ctx.size.value}px;"></div>
  </div>
`

Key differences:


Initialization: x-init → setup()

Alpine:

<div
  x-data="{ users: [] }"
  x-init="users = await (await fetch('/api/users')).json()"
>
  <template x-for="user in users" :key="user.id">
    <div x-text="user.name"></div>
  </template>
</div>

Eleva:

const UserList = {
  setup({ signal }) {
    const users = signal([]);

    // Runs on mount (like x-init)
    fetch('/api/users')
      .then(res => res.json())
      .then(data => users.value = data);

    return { users };
  },
  template: (ctx) => `
    <div>
      ${ctx.users.value.map(user => `
        <div key="${user.id}">${user.name}</div>
      `).join('')}
    </div>
  `
};

Watching Changes: $watch → signal.watch()

Alpine:

<div
  x-data="{ query: '', results: [] }"
  x-init="$watch('query', async (value) => {
    if (value.length > 2) {
      results = await search(value);
    }
  })"
>
  <input x-model="query" />
</div>

Eleva:

const Search = {
  setup({ signal }) {
    const query = signal('');
    const results = signal([]);

    query.watch(async (value) => {
      if (value.length > 2) {
        results.value = await search(value);
      }
    });

    return { query, results };
  },
  template: (ctx) => `
    <div>
      <input
        value="${ctx.query.value}"
        @input="(e) => query.value = e.target.value"
      />
    </div>
  `
};

Global State: Alpine.store → Eleva Store

Alpine:

// Define store
Alpine.store('user', {
  name: 'John',
  loggedIn: false,

  login() {
    this.loggedIn = true;
  }
});
<!-- Use in component -->
<div x-data>
  <span x-text="$store.user.name"></span>
  <button @click="$store.user.login()">Login</button>
</div>

Eleva:

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

const app = new Eleva("App");

app.use(Store, {
  state: {
    user: {
      name: 'John',
      loggedIn: false
    }
  },
  actions: {
    login: (state) => {
      state.user.value = { ...state.user.value, loggedIn: true };
    }
  }
});

// In component
const UserStatus = {
  setup({ store }) {
    return {
      user: store.state.user,
      login: () => store.dispatch('login')
    };
  },
  template: (ctx) => `
    <div>
      <span>${ctx.user.value.name}</span>
      <button @click="login">Login</button>
    </div>
  `
};

Magic Properties Comparison

Alpine Magic Eleva Equivalent
$el Access via setup() or DOM queries
$refs Use id or class selectors
$store store from setup({ store })
$watch signal.watch()
$dispatch Custom event emitter or props
$nextTick queueMicrotask()
$root Parent component reference
$data ctx in template
$id Generate IDs manually

Component Patterns

Alpine Component

<div x-data="dropdown">
  <button @click="toggle">Menu</button>
  <div x-show="open" @click.outside="close">
    <a href="#">Item 1</a>
    <a href="#">Item 2</a>
  </div>
</div>

<script>
document.addEventListener('alpine:init', () => {
  Alpine.data('dropdown', () => ({
    open: false,
    toggle() { this.open = !this.open; },
    close() { this.open = false; }
  }));
});
</script>

Eleva Component

app.component("Dropdown", {
  setup({ signal }) {
    const open = signal(false);

    const toggle = () => open.value = !open.value;
    const close = () => open.value = false;

    // Handle click outside
    const handleClickOutside = (e) => {
      if (open.value && !e.target.closest('.dropdown')) {
        close();
      }
    };

    document.addEventListener('click', handleClickOutside);

    return {
      open,
      toggle,
      close,
      onUnmount: () => document.removeEventListener('click', handleClickOutside)
    };
  },
  template: (ctx) => `
    <div class="dropdown">
      <button @click="toggle">Menu</button>
      ${ctx.open.value ? `
        <div class="dropdown-menu">
          <a href="#">Item 1</a>
          <a href="#">Item 2</a>
        </div>
      ` : ''}
    </div>
  `
});

Complete Example: Todo App

Alpine Version

<div x-data="{
  todos: [],
  newTodo: '',
  addTodo() {
    if (!this.newTodo.trim()) return;
    this.todos.push({
      id: Date.now(),
      text: this.newTodo,
      done: false
    });
    this.newTodo = '';
  },
  removeTodo(id) {
    this.todos = this.todos.filter(t => t.id !== id);
  }
}">
  <form @submit.prevent="addTodo">
    <input x-model="newTodo" placeholder="Add todo..." />
    <button type="submit">Add</button>
  </form>

  <ul>
    <template x-for="todo in todos" :key="todo.id">
      <li>
        <input type="checkbox" x-model="todo.done" />
        <span :class="{ 'line-through': todo.done }" x-text="todo.text"></span>
        <button @click="removeTodo(todo.id)">×</button>
      </li>
    </template>
  </ul>
</div>

Eleva Version

app.component("TodoApp", {
  setup({ signal }) {
    const todos = signal([]);
    const newTodo = signal('');

    const addTodo = () => {
      if (!newTodo.value.trim()) return;
      todos.value = [...todos.value, {
        id: Date.now(),
        text: newTodo.value,
        done: false
      }];
      newTodo.value = '';
    };

    const toggleTodo = (id) => {
      todos.value = todos.value.map(t =>
        t.id === id ? { ...t, done: !t.done } : t
      );
    };

    const removeTodo = (id) => {
      todos.value = todos.value.filter(t => t.id !== id);
    };

    return { todos, newTodo, addTodo, toggleTodo, removeTodo };
  },
  template: (ctx) => `
    <div>
      <form @submit="(e) => { e.preventDefault(); addTodo(); }">
        <input
          value="${ctx.newTodo.value}"
          @input="(e) => newTodo.value = e.target.value"
          placeholder="Add todo..."
        />
        <button type="submit">Add</button>
      </form>

      <ul>
        ${ctx.todos.value.map(todo => `
          <li key="${todo.id}">
            <input
              type="checkbox"
              ${todo.done ? 'checked' : ''}
              @change="() => toggleTodo(${todo.id})"
            />
            <span class="${todo.done ? 'line-through' : ''}">${todo.text}</span>
            <button @click="() => removeTodo(${todo.id})">×</button>
          </li>
        `).join('')}
      </ul>
    </div>
  `
});

app.mount(document.getElementById("app"), "TodoApp");

What You Gain with Eleva

Full JavaScript Control

Component Encapsulation

Performance

Testability


What You Lose from Alpine

HTML Simplicity

Built-in Modifiers

Progressive Enhancement

Learning Curve


When to Choose Each

Choose Alpine when:

Choose Eleva when:


Migration Checklist


← From Vue Back to Migration Overview From jQuery →