From Alpine to Aegis

Alpine puts state and behaviour into attributes on the server HTML. Aegis keeps the server HTML too, but the behaviour lives in a JavaScript function next to the markup, with signals instead of a proxy and a template parsed once instead of directives evaluated per element. If Alpine is enough for your page, keep it. The move pays off when a component grows past a dropdown: a list with server data, a form with server errors, a table.

The same component#

Alpine:

<div x-data="{ open: false, count: 12 }">
    <button @click="count++" x-text="count + ' likes'"></button>
    <button @click="open = !open">Details</button>
    <p x-show="open" x-transition>Liked by 12 people</p>
</div>

Aegis:

<div data-aegis="likes" data-count="12"><button>12 likes</button></div>

<script type="module">
import { island } from 'aegis';

island('likes', ({ props, signal, html, show }) => {
    const count = signal(props.count), open = signal(false);
    return html`
        <button @click=${() => count.value++}>${count} likes</button>
        <button @click=${() => { open.value = !open.value; }}>Details</button>
        ${show(open, () => html`<p>Liked by ${count} people</p>`, null, { transition: 'fade' })}`;
}, { types: { count: Number } });
</script>

The server markup is the initial view; the island replaces it when the module loads. Props are typed once; there is no string of JavaScript inside an attribute, so the page works under a Content-Security-Policy without unsafe-eval.

Directive by directive#

Alpine Aegis Notes
x-data="{ … }" island(name, setup) + data-* props, or mount(el, setup) state is signals created in the setup
x-text="expr" ${expr} inside `html``` a signal or a function is live, a value is a snapshot
x-html swap(el, html) for server HTML data never goes through innerHTML
x-show show(sig, () => html) the branch has its own scope and cleanup
x-if / <template> ${() => cond.value ? html : null}
x-for list(items, row => html, { key: 'id' }) keyed, minimal DOM moves
x-model bind:value=${sig}, bind:checked=${sig} picks the right event and property per control
@click, @click.outside, @keydown.enter, .prevent, .debounce the same modifiers on @event
x-bind:class="{ a: cond }" class=${{ a: cond }} signals inside the object are live
x-init the setup function body runs once, in the component's scope
$watch('count', fn) effect(() => fn(count.value)) disposed with the component
x-transition { transition: 'fade' } on show() / list() or transition() for CSS classes
Alpine.store() store() or a module-level signal() shared by import, no global
x-ref ref(): <input ${input}>
$fetch (plugins) resource(url), mutation(fn), api.post() cache, abort, optimistic updates built in
x-teleport portal(target, fn)

What does not move#

  • Alpine's x-data on many small elements across a content page: keep it, or hydrate each one as an island only if it needs data or forms.
  • Alpine.plugin(...): there is no plugin API; the equivalent helpers are imports.
  • Inline expressions in attributes: Aegis has none by design; the behaviour is JavaScript in a module.

Both on one page#

They do not conflict. Alpine ignores data-aegis elements; Aegis ignores x-data ones. Move one component at a time.