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.
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, 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.
Styles
Section titled “Styles”Use sheet() for scoped class names (no global collisions) and rcx() for reactive class toggles:
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, 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;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.
- 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.- 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 forwhen(); reach formatch()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 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.