Skip to content

A full-featured todo app demonstrating the modern Whisq idioms: bind() for two-way input binding, match() for empty states, keyed each() for efficient list reconciliation, sheet() for scoped class names, and the alpha.8 array class: form for reactive class toggles.

  • Add new todos from a text input (bind())
  • Toggle todos as done/not done (bind() on a per-todo Signal<boolean>)
  • Remove individual todos
  • Filter by all, active, or completed
  • Display count of remaining items
  • Empty-state messaging that distinguishes “no todos yet” from “none match the filter”

Separate state logic from UI. Each todo holds a nested done: Signal<boolean> so toggling a single checkbox doesn’t churn the whole array — only the one checkbox re-renders.

stores/todos.ts
import { signal, computed, effect, type Signal } from "@whisq/core";
import { randomId } from "@whisq/core/ids";
export interface Todo {
id: string;
text: string;
done: Signal<boolean>; // nested signal — per-todo reactivity
}
// ── Persistence ────────────────────────────────────────────────
// Stored shape is plain JSON: { id, text, done: boolean }.
// `done` is hydrated back into a signal on load, re-serialized on save.
// For plain-object stores, prefer the framework helper:
// import { persistedSignal } from "@whisq/core/persistence";
// export const todos = persistedSignal<Todo[]>("todos", []);
// We hand-roll loadTodos+effect here because Todo.done is a Signal<boolean>
// (nested-signals pattern) — JSON.stringify can't round-trip that without a
// custom { serialize, deserialize } pair, and at that point the inline form
// reads more clearly than the option spread.
// See /guides/state-management/#persistence-localstorage for the helper.
const STORAGE_KEY = "whisq.todos";
interface StoredTodo { id: string; text: string; done: boolean; }
function loadTodos(): Todo[] {
if (typeof localStorage === "undefined") return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as StoredTodo[];
return parsed.map((t) => ({ id: t.id, text: t.text, done: signal(t.done) }));
} catch {
return []; // corrupt payload — start fresh
}
}
// ── Store ──────────────────────────────────────────────────────
export const todos = signal<Todo[]>(loadTodos());
export const filter = signal<"all" | "active" | "done">("all");
// Write on every change. The effect reads todos.value AND every done.value
// via map(), so toggling any checkbox re-runs this effect and persists.
// Skip the eager first run so we don't rewrite the value we just loaded.
let persistInitialized = false;
effect(() => {
const list = todos.value; // establish subscription
if (!persistInitialized) { persistInitialized = true; return; }
if (typeof localStorage === "undefined") return;
try {
const serializable: StoredTodo[] = list.map((t) => ({
id: t.id, text: t.text, done: t.done.value,
}));
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable));
} catch {
// Quota exceeded or serialization failure — degrade silently.
}
});
export const filtered = computed(() => {
const list = todos.value;
switch (filter.value) {
case "active": return list.filter((t) => !t.done.value);
case "done": return list.filter((t) => t.done.value);
default: return list;
}
});
export const remaining = computed(() =>
todos.value.filter((t) => !t.done.value).length,
);
export const addTodo = (text: string) => {
if (!text.trim()) return;
todos.value = [
...todos.value,
{ id: randomId(), text: text.trim(), done: signal(false) },
];
};
export const removeTodo = (id: string) => {
todos.value = todos.value.filter((t) => t.id !== id);
};
export const clearCompleted = () => {
todos.value = todos.value.filter((t) => !t.done.value);
};

No toggleTodo() needed — each checkbox uses bind(todo.value.done, { as: "checkbox" }) (keyed each() gives an ItemAccessor<Todo> — read via .value for the alpha.8 canonical shape; the legacy todo() call form still works) and flips the per-todo signal directly.

Use sheet() for scoped class names (no global collisions); reactive toggles use the array class: form on each element:

styles/todo-app.ts
import { sheet } from "@whisq/core";
export const s = sheet({
app: { maxWidth: "420px", margin: "2rem auto", fontFamily: "system-ui" },
addForm: { display: "flex", gap: "0.5rem", marginBottom: "1rem" },
addInput: { flex: 1, padding: "0.5rem 0.75rem", fontSize: "1rem" },
list: { listStyle: "none", padding: 0, margin: 0 },
item: { display: "flex", gap: "0.5rem", padding: "0.5rem 0", alignItems: "center" },
doneText: { textDecoration: "line-through", opacity: 0.6 },
remove: { marginLeft: "auto", background: "transparent", border: "none", cursor: "pointer", fontSize: "1.2rem" },
empty: { padding: "1rem", textAlign: "center", color: "#666" },
footer: { display: "flex", alignItems: "center", gap: "1rem", marginTop: "1rem", fontSize: "0.9rem" },
filters: { display: "flex", gap: "0.25rem" },
filterBtn: { padding: "0.25rem 0.5rem", border: "1px solid #ccc", borderRadius: "4px", background: "transparent", cursor: "pointer" },
filterActive: { background: "#4386FB", color: "white", borderColor: "#4386FB" },
});
components/TodoApp.ts
import {
signal, bind, component,
div, h1, input, button, ul, li, p, span,
each, when, match,
} from "@whisq/core";
import {
todos, filtered, remaining, filter,
addTodo, removeTodo, clearCompleted,
} from "../stores/todos";
import { s } from "../styles/todo-app";
const TodoApp = component(() => {
const newText = signal("");
const handleAdd = (e: Event) => {
e.preventDefault();
addTodo(newText.value);
newText.value = "";
};
return div({ class: s.app },
h1("Todos"),
// Add form — bind(newText) spreads value + oninput onto the input
div({ class: s.addForm },
input({
...bind(newText),
placeholder: "What needs to be done?",
class: s.addInput,
onkeydown: (e) => e.key === "Enter" && handleAdd(e),
}),
button({ onclick: handleAdd }, "Add"),
),
// Empty states (two of them) + the list — match() picks first-true branch,
// with the list as the trailing fallback.
match(
[() => todos.value.length === 0,
() => p({ class: s.empty }, "No todos yet. Add one above.")],
[() => filtered.value.length === 0,
() => p({ class: s.empty }, `No ${filter.value} todos.`)],
() => ul({ class: s.list },
each(
() => filtered.value,
// Keyed render — `todo` is `() => Todo`; call it to read the current entry.
(todo) => li({ class: s.item },
// bind() receives the signal REFERENCE — captured once at render is fine
// because each todo's `done: Signal<boolean>` has stable identity.
input({ type: "checkbox", ...bind(todo.value.done, { as: "checkbox" }) }),
// Array `class:` form — reactive getter re-reads todo.value so a
// reconciler-swapped entry still resolves to the right .done.value.
span({ class: [() => todo.value.done.value && s.doneText] }, todo.value.text),
// Event handler: todo.value is read at click time, not at render time.
button({ class: s.remove, onclick: () => removeTodo(todo.value.id) }, "×"),
),
{ key: (t /* value, not accessor */) => t.id }, // keyed each — reuses DOM on reorder/toggle
),
),
),
// Footer
div({ class: s.footer },
span(() => `${remaining.value} items left`),
div({ class: s.filters },
button({
class: [s.filterBtn, () => filter.value === "all" && s.filterActive],
onclick: () => filter.value = "all",
}, "All"),
button({
class: [s.filterBtn, () => filter.value === "active" && s.filterActive],
onclick: () => filter.value = "active",
}, "Active"),
button({
class: [s.filterBtn, () => filter.value === "done" && s.filterActive],
onclick: () => filter.value = "done",
}, "Done"),
),
when(() => todos.value.some((t) => t.done.value),
() => button({ onclick: clearCompleted }, "Clear completed"),
),
),
);
});
export default TodoApp;
main.ts
import { mount } from "@whisq/core";
import TodoApp from "./components/TodoApp";
mount(TodoApp({}), document.getElementById("app")!);
  • Store pattern — state lives in a separate module; UI imports what it needs. The example uses filter.value = "all" inline in onclick handlers for readability. For apps larger than a single screen, factor mutations into named actions like setFilter("all") — see State Management → Mutations as named actions.
  • Nested signals — each todo holds its own done: Signal<boolean>. Toggling one checkbox updates only that <li>; the array signal only changes on add/remove. The filtered/remaining computeds still see every .done.value change because they read the signals during filtering.
  • bind() — spreads value + oninput (text) or checked + onchange (checkbox) in one shot. Works on the add-form input and every per-todo checkbox. Note: bind(todo.value.done, …) works here because done is a Signal<boolean> (the nested-signals pattern). If your store holds plain booleans instead — done: boolean — use bindField(todos, todo, "done", { as: "checkbox" }), which mutates the source array immutably and identifies the right item via keyBy. Both patterns are valid; bindField is the choice when you don’t want per-item signals.
  • Keyed each(){ key: (t /* value, not accessor */) => t.id } enables LIS-based diffing, so reordering or filter-toggling moves existing DOM nodes instead of recreating them. The render callback receives todo: ItemAccessor<Todo> — read via todo.value.<field> (alpha.8 canonical), todo() (legacy call form), or todo.peek() (untracked). The rule isn’t “does the field change” — it’s whether the position re-evaluates. bind(todo.value.done, ...) takes a snapshot because bind() consumes a signal reference and the signal drives its own reactivity. The class: [() => todo.value.done.value && s.doneText] array form re-evaluates its function elements on change — the getter re-reads todo.value every time, so a reconciler-swapped entry still resolves correctly. See /api/each/ for the full breakdown. For real apps, the per-item render usually lives in its own component — see Splitting into a component for the accessor-prop pattern.
  • match() — first-true-wins branching with a trailing fallback. Here: “no todos at all” → a prompt, “filtered to empty” → a specific message, otherwise render the list.
  • when() — the two-state “Clear completed” button. Two branches are fine for when(); reach for match() at three.
  • sheet() + array class: form — scoped class names (no global CSS collisions) with reactive toggles. class: [s.item, () => flag && s.active] is the canonical alpha.8 shape; rcx() continues to work and is the right tool when you need a reactive class string outside an element prop.
  • Immutable updatestodos.value = [...todos.value, newTodo] on add/remove. Mutating in place wouldn’t trigger reactivity.
  • Keyboard eventsonkeydown with e.key === "Enter" for form-less submit.

Docs current to v0.1.0-alpha.9 . All releases →