Data table
A sortable, filterable table. The trick is to keep raw rows in state and derive the visible rows with computed — sort and filter are pure transforms of the source. A keyed each then reuses row elements as the view changes, so sorting reorders nodes instead of rebuilding them.
import { html, mount, createState, computed, each } from "@zoijs/core";
function Table({ rows }) {
const source = createState(rows); // raw data
const query = createState("");
const sortKey = createState("name");
const sortDir = createState(1); // 1 asc, -1 desc
// Derived view: filter, then sort. Recomputes only when an input changes.
const visible = computed(() => {
const q = query.get().trim().toLowerCase();
const key = sortKey.get();
const dir = sortDir.get();
return source
.get()
.filter((r) => !q || r.name.toLowerCase().includes(q))
.slice() // don't sort the source in place
.sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0) * dir);
});
const sortBy = (key) => {
if (sortKey.get() === key) sortDir.set(-sortDir.get());
else {
sortKey.set(key);
sortDir.set(1);
}
};
const arrow = (key) => () =>
sortKey.get() !== key ? "" : sortDir.get() === 1 ? " ▲" : " ▼";
const header = (key, label) => html`
<th>
<button onclick=${() => sortBy(key)} aria-label=${`Sort by ${label}`}>
${label}${arrow(key)}
</button>
</th>
`;
return html`
<section>
<input
type="search"
value=${() => query.get()}
oninput=${(e) => query.set(e.target.value)}
placeholder="Filter by name…"
aria-label="Filter rows"
/>
<table>
<thead>
<tr>${header("name", "Name")}${header("email", "Email")}${header("age", "Age")}</tr>
</thead>
<tbody>
${each(
() => visible.get(),
(r) => r.id,
(r) => html`
<tr>
<td>${r.name}</td>
<td>${r.email}</td>
<td>${r.age}</td>
</tr>
`
)}
</tbody>
</table>
<p>${() => visible.get().length} of ${() => source.get().length} rows</p>
</section>
`;
}
mount(() => Table({ rows: window.__ROWS__ ?? [] }), "#app");Why it stays fast#
- Derive, don't duplicate.
visibleis acomputedoversource,query,sortKey, andsortDir. It's lazy, cached, and value-gated — it recomputes only when one of those actually changes, and if the result is equal it doesn't wake the table. - Keyed rows survive reordering. Because
eachkeys byr.id, sorting moves the existing<tr>nodes (the minimal set of moves) rather than recreating them. Anything stateful in a row — a focused input, an expanded detail, scroll — is preserved. - Never sort in place.
.slice()before.sort()keeps the source array's order stable, so the only thing that changes between renders is the derived view. - Inert cells.
${r.name}and friends render as text, so a value like="><script>is shown literally. See Security.
For huge tables, page or windowize visible (render a slice around the scroll offset); the same keyed each reuses nodes as the window moves — see Infinite scroll.
Next: Charts (via a plain library).