Todo App
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.
What You’ll Build
Section titled “What You’ll Build”- Add new todos from a text input (
bind()) - Toggle todos as done/not done (
bind()on a per-todoSignal<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”
The Store
Section titled “The Store”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.
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.
Styles
Section titled “Styles”Use sheet() for scoped class names (no global collisions); reactive toggles use the array class: form on each element:
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" },});The Component
Section titled “The Component”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;Entry Point
Section titled “Entry Point”import { mount } from "@whisq/core";import TodoApp from "./components/TodoApp";
mount(TodoApp({}), document.getElementById("app")!);Key Concepts
Section titled “Key Concepts”- 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 likesetFilter("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. Thefiltered/remainingcomputeds still see every.done.valuechange because they read the signals during filtering. bind()— spreadsvalue + oninput(text) orchecked + onchange(checkbox) in one shot. Works on the add-form input and every per-todo checkbox. Note:bind(todo.value.done, …)works here becausedoneis aSignal<boolean>(the nested-signals pattern). If your store holds plain booleans instead —done: boolean— usebindField(todos, todo, "done", { as: "checkbox" }), which mutates the source array immutably and identifies the right item viakeyBy. Both patterns are valid;bindFieldis 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 receivestodo: ItemAccessor<Todo>— read viatodo.value.<field>(alpha.8 canonical),todo()(legacy call form), ortodo.peek()(untracked). The rule isn’t “does the field change” — it’s whether the position re-evaluates.bind(todo.value.done, ...)takes a snapshot becausebind()consumes a signal reference and the signal drives its own reactivity. Theclass: [() => todo.value.done.value && s.doneText]array form re-evaluates its function elements on change — the getter re-readstodo.valueevery 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 forwhen(); reach formatch()at three.sheet()+ arrayclass: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 updates —
todos.value = [...todos.value, newTodo]on add/remove. Mutating in place wouldn’t trigger reactivity. - Keyboard events —
onkeydownwithe.key === "Enter"for form-less submit.
Next Steps
Section titled “Next Steps”- Forms guide — deep dive on
bind()for every input type. - Data Fetching —
resource()andmatch()for loading/error/data. - List Rendering — when keyed vs. unkeyed
each()matters. - State Management — more on the store pattern + nested signals.
Docs current to v0.1.0-alpha.9 . All releases →