Chapter 5: Toggling and Updating
Our todo list displays items, but clicking the checkbox doesn’t do anything. Let’s make it interactive by handling the toggle action.
The Challenge
We need to:
- Detect when a checkbox is clicked
- Find the corresponding todo in our array
- Toggle its
completedstatus - Update the UI (automatically, thanks to reactivity!)
Passing a Toggle Handler
Since the todos Signal lives in App, we need to pass a toggle function down to TodoItem.
First, update the props interface and component in src/components/TodoItem.ts:
import { Effect } from "effect";
import { $, collect, Readable } from "@effex/dom";
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoItemProps {
todo: Readable<Todo>;
onToggle: (id: number) => Effect.Effect<void>;
}
export const TodoItem = (props: TodoItemProps) =>
Effect.gen(function* () {
const todoId = yield* Readable.map(props.todo, t => t.id).get;
return yield* $.li(
{
class: Readable.map(props.todo, t =>
t.completed ? "todo-item completed" : "todo-item"
),
},
collect(
$.input({
type: "checkbox",
class: "toggle",
checked: Readable.map(props.todo, t => t.completed),
onChange: () => props.onToggle(todoId),
}),
$.span({ class: "todo-text" }, $.of(Readable.map(props.todo, t => t.text))),
),
);
});
What changed:
onToggle: (id: number) => Effect.Effect<void>- Added to props, returns an EffecttodoId- We read the ID once at mount time (IDs are stable)onChange- CallsonToggle(todoId)which returns an Effect- Conditional class -
Readable.map(props.todo, t => t.completed ? "completed" : "")adds a class when completed
Implementing the Toggle in App
Now update src/main.ts to create the toggle function and pass it down:
import "./styles.css";
import { Effect } from "effect";
import { $, collect, each, mount, Readable, runApp, Signal } from "@effex/dom";
import { TodoItem } from "./components/TodoItem";
const container = document.getElementById("root");
if (!container) throw new Error("Root element not found");
interface Todo {
id: number;
text: string;
completed: boolean;
}
const App = () =>
Effect.gen(function* () {
const todos = yield* Signal.Array.make<Todo>([
{ id: 1, text: "Learn Effex", completed: false },
{ id: 2, text: "Build a todo app", completed: false },
{ id: 3, text: "Ship it!", completed: false },
]);
// Toggle a todo's completed status
const toggleTodo = (id: number) =>
todos.update(items =>
items.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
return yield* $.div({ class: "todo-app" },
collect(
$.header({ class: "header" },
collect(
$.h1({}, $.of("todos")),
$.input({
class: "new-todo",
placeholder: "What needs to be done?",
autofocus: true,
}),
),
),
$.main({ class: "main" },
$.ul(
{ class: "todo-list" },
each(todos, {
key: (todo) => todo.id,
render: (todo) => TodoItem({ todo, onToggle: toggleTodo }),
}),
),
),
$.footer({ class: "footer" },
$.span(
{ class: "todo-count" },
$.of(Readable.map(todos, t => {
const remaining = t.filter(todo => !todo.completed).length;
return `${remaining} item${remaining === 1 ? "" : "s"} left`;
})),
),
),
),
);
});
runApp(mount(App(), container));
The key addition is toggleTodo:
const toggleTodo = (id: number) =>
todos.update(items =>
items.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
This:
- Takes a todo ID
- Maps over all todos
- Finds the one with matching ID and toggles its
completedproperty - Returns an Effect that updates the Signal
Add Completed Styling
Update your CSS to show completed todos differently:
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #aaa;
}
Now click a checkbox! The todo should:
- Get checked
- Show a strikethrough
- Add the “completed” class
Understanding the Flow
Here’s what happens when you click a checkbox:
onChangefires → callsonToggle(todoId)toggleTodoruns → returnstodos.update(...)EffecttodosSignal updates with new arrayeachdetects the change- The specific
TodoItem’stodoReadable updates Readable.map()derivations recompute (completed,class)- DOM updates - only the checkbox and span change
No re-render of the whole list. No diffing algorithm. Just precise, surgical updates.
Updating the Count
Our footer still shows total items. Let’s fix it to show only incomplete items:
$.span(
{ class: "todo-count" },
$.of(Readable.map(todos, t => {
const remaining = t.filter(todo => !todo.completed).length;
return `${remaining} item${remaining === 1 ? "" : "s"} left`;
})),
),
Now the count updates as you toggle todos!
Key Takeaways
- Pass callbacks down to child components for updates
signal.update()returns an Effect that modifies state immutably- Conditional classes work with
Readable.map()returning different strings - Event handlers return Effects
- Updates are fine-grained—only changed parts of the DOM update