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. visible is a computed over source, query, sortKey, and sortDir. 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 each keys by r.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).