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.
todosis the only source of truth. Create/toggle/delete eachrun()their request and thentodos.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()anderror(), 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.