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-dataon 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.