Chapter 6: Adding New Todos
Time to make that input field work! We’ll capture user input and add new todos to our list.
The Plan
- Track the input field’s value in a Signal
- Listen for Enter key press
- Add a new todo to the list
- Clear the input field
Adding Input State
Update src/main.ts to add a Signal for the input value:
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 },
]);
// Track the input field value
const newTodoText = yield* Signal.make("");
const toggleTodo = (id: number) =>
todos.update(items =>
items.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
// Add a new todo
const addTodo = () =>
Effect.gen(function* () {
const text = yield* newTodoText.get;
if (text.trim()) {
yield* todos.push({ id: Date.now(), text: text.trim(), completed: false });
yield* newTodoText.set("");
}
});
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,
value: newTodoText,
onInput: (e) => newTodoText.set((e.target as HTMLInputElement).value),
onKeyDown: (e) => {
if (e.key === "Enter") return addTodo();
return Effect.void;
},
}),
),
),
// ... rest of the app
),
);
});
Breaking It Down
Two-Way Binding
$.input({
value: newTodoText,
onInput: (e) => newTodoText.set((e.target as HTMLInputElement).value),
})
value: newTodoText- Binds the input’s value to the Signal (display)onInput- Returns an Effect that updates the Signal when the user types (input)
This creates two-way binding: the Signal controls the input, and the input updates the Signal.
Effect-Based Handlers
The addTodo function uses Effect.gen to read and write Signals:
const addTodo = () =>
Effect.gen(function* () {
const text = yield* newTodoText.get; // Read value via Effect
if (text.trim()) {
yield* todos.update(items => [ // Update via Effect
...items,
{ id: Date.now(), text: text.trim(), completed: false }
]);
yield* newTodoText.set(""); // Set via Effect
}
});
Signal operations like .get, .set(), and .update() all return Effects. Use yield* inside Effect.gen to execute them.
Handling Enter Key
onKeyDown: (e) => {
if (e.key === "Enter") return addTodo();
return Effect.void;
}
We check for the Enter key and return the addTodo() Effect. For other keys, we return Effect.void (a no-op Effect).
The Complete App So Far
Here’s the full src/main.ts:
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 },
]);
const newTodoText = yield* Signal.make("");
const toggleTodo = (id: number) =>
todos.update(items =>
items.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
const addTodo = () =>
Effect.gen(function* () {
const text = yield* newTodoText.get;
if (text.trim()) {
yield* todos.push({ id: Date.now(), text: text.trim(), completed: false });
yield* newTodoText.set("");
}
});
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,
value: newTodoText,
onInput: (e) => newTodoText.set((e.target as HTMLInputElement).value),
onKeyDown: (e) => {
if (e.key === "Enter") return addTodo();
return Effect.void;
},
}),
),
),
$.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(yield* App(), container));
Try It Out
- Type in the input field
- Press Enter
- Watch the new todo appear!
- The input clears automatically
- The count updates
Key Takeaways
- Two-way binding with
value+onInputreturning an Effect - Signal operations (
.get,.set(),.update()) return Effects — useyield*inEffect.gen - Event handlers return Effects (use
Effect.voidfor no-ops) - New items appear automatically thanks to
eachand reactivity