4. Add todos with a form
Build a controlled form with useStore, handle submit, focus the input on mount and add new todos to the list.
Goal: a form above the list that adds a todo when submitted, ignores blank input and starts focused. Forms are covered in depth by the guide Forms.
The add operation#
Extend the hook from the previous step. The id is one more than the largest id in the store, which keeps ids unique without a counter that lives outside the store.
export interface Todo {
id: number;
title: string;
done: boolean;
}
import { useStore } from "@shadow-js/core";
import type { Todo } from "./types";
export function useTodos(initial: readonly Todo[] = []) {
const [state, setState] = useStore({ todos: [...initial] });
return {
state,
add(title: string): boolean {
const text = title.trim();
if (text === "") return false;
setState((draft) => {
const id = draft.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1;
draft.todos.push({ id, title: text, done: false });
});
return true;
},
toggle(id: number) {
setState("todos", (todo) => todo.id === id, "done", (done) => !done);
},
remove(id: number) {
setState((draft) => {
const index = draft.todos.findIndex((todo) => todo.id === id);
if (index !== -1) draft.todos.splice(index, 1);
});
},
};
}
The form component#
The text the user is typing is local state, so it lives in the component that owns the input. useStore("")
returns a read function and a setter. The input's value is bound to the read function, and onInput writes
back: that is a controlled input.
import { onMount, useStore } from "@shadow-js/core";
export interface AddTodoProps {
onAdd: (title: string) => void;
}
export function AddTodo(props: AddTodoProps) {
const [title, setTitle] = useStore("");
let input: HTMLInputElement | undefined;
onMount(() => {
input?.focus();
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
props.onAdd(title());
setTitle("");
}}
>
<input
ref={(element) => {
input = element;
}}
type="text"
aria-label="New todo"
placeholder="What needs to be done?"
value={title()}
onInput={(event) => setTitle(event.currentTarget.value)}
/>
<button type="submit" disabled={title().trim() === ""}>
Add
</button>
</form>
);
}
refreceives the element once it is created. A callback keeps the assignment visible and type safe.onMountruns once, untracked, in the first flush after the component is created, when the nodes it returned are in the document. It never runs on the server, so it is the right place forfocus()and other browser-only work.value={title()}anddisabled={title().trim() === ""}are bindings. Typing updates the store, the store updates the button, and no component function runs again.submitis not a delegated event; the handler is bound directly on the form. Either way, the framework runs an event handler as one batch and flushes at its end, so the DOM is up to date when your handler returns.
Put it in the page#
todos.add is passed as the callback. It returns false for blank input, which AddTodo does not need to
care about.
import type { Todo } from "./types";
export interface TodoItemProps {
todo: Todo;
onToggle: () => void;
onRemove: () => void;
}
export function TodoItem(props: TodoItemProps) {
return (
<li classList={{ done: props.todo.done }}>
<label>
<input type="checkbox" checked={props.todo.done} onChange={() => props.onToggle()} />
{props.todo.title}
</label>
<button
type="button"
aria-label={`Delete ${props.todo.title}`}
onClick={() => props.onRemove()}
>
Delete
</button>
</li>
);
}
import { For, Show } from "@shadow-js/web";
import { AddTodo } from "./AddTodo";
import { TodoItem } from "./TodoItem";
import { useTodos } from "./todos";
export function App() {
const todos = useTodos([
{ id: 1, title: "Learn ShadowJS", done: false },
{ id: 2, title: "Write a todo app", done: true },
]);
const remaining = todos.state.todos.filter((todo) => !todo.done).length;
return (
<main>
<h1>Todos</h1>
<AddTodo onAdd={todos.add} />
<Show when={todos.state.todos.length > 0} fallback={<p>Nothing to do.</p>}>
<ul>
<For each={todos.state.todos}>
{(todo) => (
<TodoItem
todo={todo}
onToggle={() => todos.toggle(todo.id)}
onRemove={() => todos.remove(todo.id)}
/>
)}
</For>
</ul>
</Show>
<p>{remaining} left</p>
</main>
);
}
What you should see#
The input has focus when the page loads. Typing enables the Add button; submitting appends a row, clears the input and updates the counter. Blank or whitespace-only input is ignored.
Next: 5. Filters and derived state.