Skip to content

Your First Component

Let’s build a real component: a todo list with add, toggle, and delete.

Start with the state. A todo has an id, text, and done flag:

import { signal } from "@whisq/core";
interface Todo {
id: number;
text: string;
done: boolean;
}
const todos = signal<Todo[]>([
{ id: 1, text: "Learn Whisq", done: false },
{ id: 2, text: "Build something", done: false },
]);
let nextId = 3;

Each todo is a li with a checkbox, text, and delete button:

import { li, input, span, button } from "@whisq/core";
function TodoItem(todo: Todo) {
return li({ class: () => todo.done ? "done" : "" },
input({
type: "checkbox",
checked: todo.done,
onchange: () => toggleTodo(todo.id),
}),
span(todo.text),
button({ onclick: () => removeTodo(todo.id) }, ""),
);
}

Functions that update the signal:

function addTodo(text: string) {
todos.value = [...todos.value, { id: nextId++, text, done: false }];
}
function toggleTodo(id: number) {
todos.value = todos.value.map(t =>
t.id === id ? { ...t, done: !t.done } : t
);
}
function removeTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id);
}

An input with an “Add” button:

import { signal, div, form, input, button } from "@whisq/core";
function AddTodo() {
const text = signal("");
return form({
onsubmit: (e) => {
e.preventDefault();
if (text.value.trim()) {
addTodo(text.value.trim());
text.value = "";
}
},
},
input({
type: "text",
placeholder: "What needs doing?",
value: () => text.value,
oninput: (e) => text.value = (e.target as HTMLInputElement).value,
}),
button({ type: "submit" }, "Add"),
);
}

Put it all together:

import {
signal, computed, component,
div, h1, p, ul, form, input, button, li, span,
mount,
} from "@whisq/core";
interface Todo {
id: number;
text: string;
done: boolean;
}
const todos = signal<Todo[]>([
{ id: 1, text: "Learn Whisq", done: false },
{ id: 2, text: "Build something", done: false },
]);
let nextId = 3;
const remaining = computed(() =>
todos.value.filter(t => !t.done).length
);
function addTodo(text: string) {
todos.value = [...todos.value, { id: nextId++, text, done: false }];
}
function toggleTodo(id: number) {
todos.value = todos.value.map(t =>
t.id === id ? { ...t, done: !t.done } : t
);
}
function removeTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id);
}
const App = component(() => {
const text = signal("");
return div({ class: "app" },
h1("Whisq Todos"),
form({
onsubmit: (e) => {
e.preventDefault();
if (text.value.trim()) {
addTodo(text.value.trim());
text.value = "";
}
},
},
input({
type: "text",
placeholder: "What needs doing?",
value: () => text.value,
oninput: (e) => text.value = (e.target as HTMLInputElement).value,
}),
button({ type: "submit" }, "Add"),
),
ul(
() => todos.value.map(todo =>
li({ class: () => todo.done ? "done" : "" },
input({
type: "checkbox",
checked: todo.done,
onchange: () => toggleTodo(todo.id),
}),
span(todo.text),
button({ onclick: () => removeTodo(todo.id) }, ""),
)
),
),
p(() => `${remaining.value} item${remaining.value !== 1 ? "s" : ""} remaining`),
);
});
mount(App({}), document.getElementById("app")!);

In this tutorial you used:

  • signal() — reactive state for the todo list and input text
  • computed() — derived value (remaining count)
  • component() — the root app component
  • Element functionsdiv, h1, ul, li, form, input, button, span, p
  • Reactive children() => todos.value.map(...) for dynamic lists
  • Reactive propsclass: () => ... for conditional styling
  • Eventsonclick, onsubmit, oninput, onchange
  • Signals — Deep dive into signal(), computed(), effect()
  • Elements — All the element functions and props
  • Components — Lifecycle, context, error boundaries