CRUD

List, create, edit, and delete against a REST API. Reads go through @zoijs/resource (loading / data / error / refresh); writes go through @zoijs/action (pending / error / run). After a successful write, refresh() the list — one source of truth, no manual cache juggling.

import { html, mount, createState, each } from "@zoijs/core";
import { resource } from "@zoijs/resource";
import { action } from "@zoijs/action";

const API = "/api/todos";
const json = (r) => {
  if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
  return r.json();
};

function Todos() {
  // READ — loads once, refresh() reloads.
  const todos = resource(() => fetch(API).then(json));

  // WRITES — each is an action; on success, refresh the list.
  const create = action(async (title) => {
    await fetch(API, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title, done: false }),
    }).then(json);
    todos.refresh();
  });

  const toggle = action(async (todo) => {
    await fetch(`${API}/${todo.id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ done: !todo.done }),
    }).then(json);
    todos.refresh();
  });

  const remove = action(async (id) => {
    await fetch(`${API}/${id}`, { method: "DELETE" });
    todos.refresh();
  });

  const draft = createState("");
  const submit = (e) => {
    e.preventDefault();
    const title = draft.get().trim();
    if (title) create.run(title).then(() => draft.set(""));
  };

  return html`
    <section>
      <form onsubmit=${submit}>
        <input
          value=${() => draft.get()}
          oninput=${(e) => draft.set(e.target.value)}
          placeholder="New todo"
        />
        <button type="submit" disabled=${() => create.pending()}>Add</button>
      </form>

      ${() => create.error() && html`<p class="error">Couldn't add that.</p>`}

      ${() => todos.loading() && !todos.data() && html`<p>Loading…</p>`}
      ${() => todos.error() && html`<p class="error">Failed to load. <button onclick=${() => todos.refresh()}>Retry</button></p>`}

      <ul>
        ${each(
          () => todos.data() ?? [],
          (t) => t.id,
          (t) => html`
            <li>
              <label>
                <input type="checkbox" checked=${() => t.done} onchange=${() => toggle.run(t)} />
                ${t.title}
              </label>
              <button onclick=${() => remove.run(t.id)}>Delete</button>
            </li>
          `
        )}
      </ul>
    </section>
  `;
}

mount(Todos, "#app");

What's happening#

  • One read, many writes. todos is the only source of truth. Create/toggle/delete each run() their request and then todos.refresh(), so the UI always reflects the server. refresh() keeps the old data on screen until the new load lands — no flash.
  • Per-write feedback. Each action exposes its own pending() and error(), so the Add button disables itself while in flight and you can show a targeted error without a global spinner.
  • Keyed rows. each(..., (t) => t.id, ...) keys by id, so toggling or deleting one todo updates exactly that row — focus and scroll on the rest are untouched.
  • Safe by default. ${t.title} renders as inert text; even a title of <img onerror=…> shows as characters, never executes. See Security.

Optimistic updates (optional)#

For a snappier feel, update the local list before the request and roll back on error. Keep the list in a createState the resource seeds, mutate it immediately in the action, and re-refresh() (or restore the snapshot) if the request throws. Reach for this only where the latency is visible — the refresh-after-write version above is simpler and correct, which is the right default.


Next: Debounced search.