Examples
Every recipe is one self-contained HTML file that imports aegis.js directly, no build. Read the source, open it in a new tab, or press Edit to continue in the playground.
Island on a server pageisland(), typed data-* props, mutation with optimistic update
<!-- rendered by the server: visible before JS loads, replaced by the island's template after hydration -->
<ul data-aegis="likes" data-url="/api/likes" data-count="12">
<li>12 likes</li>
</ul>
<script type="module">
import { island, mutation, defaults } from '../aegis.js';
defaults.fetcher = async () => ({ ok: true }); // demo: no server behind /api/likes
island('likes', ({ props, signal, html }) => {
const count = signal(props.count); // data-count="12" → 12 (types)
const like = mutation(() => defaults.fetcher(props.url), { optimistic: () => count.value++ });
return html`<li>${count} likes <button @click=${like} ?disabled=${like.pending}>♥</button></li>`;
}, { types: { count: Number } });
// no mount(): every [data-aegis] on the page is hydrated after island()
</script>Search with debounceresource() from a query signal, when() with an empty state, keepPrevious
<div id="app"></div>
<script type="module">
import { mount, resource } from '../aegis.js';
const CITIES = ['Berlin', 'Boston', 'Lisbon', 'London', 'Madrid', 'Milan', 'Paris', 'Prague'];
// demo fetcher: any resource() with { fetcher } talks to it instead of the network
const fetcher = async (url) => { await new Promise(r => setTimeout(r, 150)); const q = new URL(url, location.href).searchParams.get('q').toLowerCase(); return CITIES.filter(c => c.toLowerCase().includes(q)); };
mount('#app', ({ signal, html, when, list }) => {
const q = signal('');
// URL is a function of the query: empty query → null → no request; a new query aborts the previous one
const hits = resource(() => q.value.trim() ? `/api/cities?q=${encodeURIComponent(q.value.trim())}` : null, { fetcher, keepPrevious: true });
return html`
<input placeholder="Search cities…" @input.debounce.300=${(e) => { q.value = e.target.value; }}>
${when(hits, {
loading: () => html`<p>Searching…</p>`,
empty: () => html`<p>${() => q.value.trim() ? 'No city matches' : 'Type a city: Berlin, London, Paris…'}</p>`,
error: (e, retry) => html`<p>${e.message} <button @click=${retry}>Retry</button></p>`,
data: (rows, rowsSignal) => html`<ul class=${{ dim: hits.validating }}>${list(rowsSignal, (c) => html`<li>${c}</li>`, { key: (c) => c })}</ul>`,
})}`;
});
</script>
<style>.dim { opacity: .5 }</style>Progressive formwireForm() over a plain <form>: validation, a mock server error, submit state
<!-- a normal server form: works without JS; the island adds live validation on top -->
<form data-aegis="signup" method="post" action="/signup">
<label>Email <input name="email" type="email" required></label>
<label>Password <input name="password" type="password" required minlength="8"></label>
<label>Repeat <input name="repeat" type="password" required></label>
<button>Create account</button>
</form>
<script type="module">
import { island, wireForm, required, emailRule, minLen, matches } from '../aegis.js';
island('signup', ({ el, html }) => {
const f = wireForm(el, {
schema: {
email: [required, emailRule],
password: [required, minLen(8)],
repeat: [required, matches('password', 'Passwords differ')],
},
mode: 'blur-then-live', // quiet until the field is left, live after that
// mock server (try me@taken.com): a real app passes submit: true and lets the 422 → field errors path do this
submit: async (v) => { await new Promise(r => setTimeout(r, 500)); if (v.email.endsWith('@taken.com')) { f.setErrors({ email: 'This email is already registered' }); throw new Error('422'); } },
});
// f.errors.email, f.valid, f.submitting are signals; errors are also written into the DOM (aria-invalid, :user-invalid)
el.insertAdjacentElement('afterend', html`<p aria-live="polite">${() => f.submitting.value ? 'Creating…' : f.status.value === 'success' ? 'Account created (mock)' : ''}</p>`.firstElementChild);
});
</script>
<style>input:user-invalid { outline: 2px solid #C8353B }</style>Modal<dialog> driven by a signal, focus trap, Escape and backdrop click
<div id="app"></div>
<dialog id="confirm">
<form method="dialog">
<p>Delete the project?</p>
<button value="cancel">Cancel</button>
<button value="ok" autofocus>Delete</button>
</form>
</dialog>
<script type="module">
import { mount, modal } from '../aegis.js';
mount('#app', ({ signal, html, on }) => {
const open = signal(false);
const status = signal('');
const dialog = document.querySelector('#confirm');
modal(dialog, open); // showModal()/close() follow the signal; Esc and a backdrop click write it back
on(dialog, 'close', () => { status.value = dialog.returnValue === 'ok' ? 'Deleted' : 'Kept'; });
return html`
<button @click=${() => { dialog.returnValue = ''; open.value = true; }}>Delete project…</button>
<p>${status}</p>`;
});
</script>Sortable, filterable tablereactive() + list() keyed rows, computed sort
<div id="app"></div>
<script type="module">
import { mount, reactive } from '../aegis.js';
const ROWS = Array.from({ length: 200 }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}`, price: (i * 37) % 500, stock: i % 7 }));
mount('#app', ({ html, list, computed, signal }) => {
const state = reactive({ rows: ROWS, q: '', sortBy: 'id', dir: 1 });
// getters on reactive() are computeds; array methods track one dependency per call
const view = computed(() => state.rows
.filter(r => r.name.toLowerCase().includes(state.q.toLowerCase()))
.toSorted((a, b) => (a[state.sortBy] > b[state.sortBy] ? 1 : -1) * state.dir));
const sort = (key) => { if (state.sortBy === key) state.dir *= -1; else { state.sortBy = key; state.dir = 1; } };
const th = (key, label) => html`<th @click=${() => sort(key)} class=${{ active: () => state.sortBy === key }}>${label}</th>`;
return html`
<input placeholder="Filter…" @input=${(e) => { state.q = e.target.value; }}>
<span>${() => view.value.length} rows</span>
<table>
<thead><tr>${th('id', '#')}${th('name', 'Name')}${th('price', 'Price')}${th('stock', 'Stock')}</tr></thead>
<tbody>${list(view, (r) => html`<tr class=${{ low: r.stock === 0 }}><td>${r.id}</td><td>${r.name}</td><td>${r.price}</td><td>${r.stock}</td></tr>`, { key: 'id' })}</tbody>
</table>`;
});
</script>
<style>th { cursor: pointer } th.active { text-decoration: underline } tr.low { color: crimson }</style>Bigger things
Admin app
Hash router, table with search and paging, optimistic mutations, forms with server errors, a 50 000-line virtual log, offline settings, theme and i18n on a mock server. One file.
Open the demo →Benchmark
A js-framework-benchmark-style table (1 000 rows, list() + html``) plus the reactive core. Numbers land in a <pre> and window.__bench.
DevTools
The in-page inspector is itself an Aegis app: component tree, signals with change marks, effects and their dependencies, cache tab. Add ?aegis-devtools to any page.