Skip to content

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, and sheet() + rcx() for scoped styles with 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, type Signal } from "@whisq/core";
export interface Todo {
id: number;
text: string;
done: Signal<boolean>;
}
let nextId = 1;
export const todos = signal<Todo[]>([]);
export const filter = signal<"all" | "active" | "done">("all");
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: nextId++, text: text.trim(), done: signal(false) },
];
};
export const removeTodo = (id: number) => {
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.done, { as: "checkbox" }) and flips the per-todo signal directly.

Use sheet() for scoped class names (no global collisions) and rcx() for reactive class toggles:

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, rcx,
} 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,
(todo) => li({ class: s.item },
// bind(todo.done) — per-todo checkbox, reactive per signal
input({ type: "checkbox", ...bind(todo.done, { as: "checkbox" }) }),
// rcx() toggles the scoped .doneText class when the signal flips
span({ class: rcx(() => todo.done.value && s.doneText) }, todo.text),
button({ class: s.remove, onclick: () => removeTodo(todo.id) }, "×"),
),
{ key: (todo) => todo.id }, // keyed each — reuses DOM on reorder/toggle
),
),
),
// Footer
div({ class: s.footer },
span(() => `${remaining.value} items left`),
div({ class: s.filters },
button({
class: rcx(s.filterBtn, () => filter.value === "all" && s.filterActive),
onclick: () => filter.value = "all",
}, "All"),
button({
class: rcx(s.filterBtn, () => filter.value === "active" && s.filterActive),
onclick: () => filter.value = "active",
}, "Active"),
button({
class: rcx(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.
  • 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.
  • Keyed each(){ key: (t) => t.id } enables LIS-based diffing, so reordering or filter-toggling moves existing DOM nodes instead of recreating them.
  • 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() + rcx() — scoped class names (no global CSS collisions) with reactive toggles. rcx(s.item, () => flag && s.active) reads cleanly and is the recommended pattern over ternaries.
  • 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.