The interactive example loads in the browser.
The interactive example loads in the browser.
Create two items, edit one, then press Escape. The label should keep its original text. We need stable rows and a separate draft to make that work.
Give each row its ID
In src/presentation.ts, the list delegates each item to TodoItem:
<section class="main">
<ul class="todo-list">
${many(App.TodoList, (todo) => todo.id, TodoItem)}
</ul>
</section>
many matches rows by Todo ID. Prepending another item can move an existing row without recreating it. Titles and array positions cannot make that promise.
Allocate state inside the row
const TodoItem = component(function* (todo: RefSubject.RefSubject<Domain.Todo>, id: Domain.TodoId) {
// A keyed row owns its draft until that item is hidden or removed.
const editing = yield* RefSubject.make(false);
const draft = yield* RefSubject.make("");
const text = RefSubject.map(todo, (value) => value.text);
const completed = RefSubject.map(todo, (value) => value.completed);
Here component() earns its place: each mounted row needs its own editing flag and draft. text and completed are read views of its Todo. The outer TodoApp remains a direct html template because it allocates nothing.
Begin, cancel, and save
const begin = text.pipe(
Effect.flatMap((value) => RefSubject.set(draft, value)),
Effect.flatMap(() => RefSubject.set(editing, true)),
);
// Only Save writes committed text, so Cancel needs no rollback.
const cancel = RefSubject.set(editing, false);
const save = draft.pipe(
Effect.flatMap((value) => App.editTodo(id, value)),
Effect.flatMap(() => cancel),
);
Beginning copies committed text into the draft. Cancelling only hides the editor. Saving sends the draft to App.editTodo before closing it. Typing into committed text directly would leave nothing for Cancel to preserve.
The application action trims saved text and deletes a Todo when that text is blank. That policy stays outside the row’s browser handlers.
Bind the editor to the draft
<input
class="edit"
aria-label="Edit todo"
.value=${draft}
oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
RefSubject.set(draft, event.target.value),
)}
onkeydown=${EventHandler.make((event: KeyboardEvent) => (event.key === "Escape" ? cancel : undefined))}
/>
<button type="submit">Save</button>
<button type="button" onclick=${cancel}>Cancel</button>
</form>`,
Fx.if(editing, …) selects this editor or the ordinary label and controls. Enter submits its form; Escape and Cancel discard the draft. Edit is also a button, so double-clicking the label is not the only way to begin.
Try it: keep one row in edit mode while adding another above it. Then cancel, edit again, and save. If rows share drafts, check where those subjects were allocated and whether IDs are unique. Removing or filtering out a row ends its rendered lifetime; an unfinished draft is not persisted application data.
Continue with keyed collections for the identity mechanism. Next, derive the footer from the same list.
Complete files
Keep the files from the previous step and replace or add these. Each full file is the source used by this milestone; the excerpts above select lines from it.
src/domain.ts
import * as Schema from "effect/Schema";
// Titles can change or match; the ID remains the item's identity.
export const TodoId = Schema.String.pipe(Schema.brand("TodoId"));
export type TodoId = typeof TodoId.Type;
export const Todo = Schema.Struct({
id: TodoId,
text: Schema.String,
completed: Schema.Boolean,
timestamp: Schema.DateTimeUtcFromString,
});
export type Todo = typeof Todo.Type;
export const TodoList = Schema.Array(Todo);
export type TodoList = typeof TodoList.Type;
export const FilterState = Schema.Literals(["all", "active", "completed"]);
export type FilterState = typeof FilterState.Type;
// Keep untouched item objects, even though the array itself changes.
export const updateTodo =
(id: TodoId, f: (todo: Todo) => Todo) =>
(list: TodoList): TodoList =>
list.map((todo) => (todo.id === id ? f(todo) : todo));
export const editText = (id: TodoId, text: string) => updateTodo(id, (todo) => ({ ...todo, text }));
export const updateText =
(text: string) =>
(todo: Todo): Todo => ({ ...todo, text });
export const toggleCompleted =
(id: TodoId) =>
(list: TodoList): TodoList =>
updateTodo(id, (todo) => ({ ...todo, completed: !todo.completed }))(list);
export const deleteTodo =
(id: TodoId) =>
(list: TodoList): TodoList =>
list.filter((todo) => todo.id !== id);
export const clearCompleted = (list: TodoList): TodoList => list.filter((todo) => !todo.completed);
export const activeCount = (list: TodoList): number =>
list.filter((todo) => !todo.completed).length;
export const someAreCompleted = (list: TodoList): boolean => list.some((todo) => todo.completed);
// An empty list should not check the "mark all" control.
export const allAreCompleted = (list: TodoList): boolean =>
list.length > 0 && list.every((todo) => todo.completed);
export const toggleAllCompleted = (list: TodoList): TodoList => {
const completed = list.some((todo) => !todo.completed);
return list.map((todo) => ({ ...todo, completed }));
};
export const filterTodoList = ({ list, state }: { list: TodoList; state: FilterState }) =>
state === "active"
? list.filter((todo) => !todo.completed)
: state === "completed"
? list.filter((todo) => todo.completed)
: list;
src/application.ts
import { Context, Effect } from "effect";
import { RefArray, RefSubject } from "@typed/fx";
import * as Domain from "./domain.js";
// Layers supply these cells, so each application or test can own fresh state.
export class TodoList extends RefSubject.Service<TodoList, Domain.TodoList>()("TodoList") {}
export class TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {}
export class CreateTodo extends Context.Service<
CreateTodo,
(text: string) => Effect.Effect<Domain.Todo>
>()("CreateTodo") {}
export const createTodo = Effect.gen(function* () {
const text = yield* TodoText;
if (text.trim() === "") return;
const create = yield* CreateTodo;
// Commit a complete item before clearing the draft; a failed factory preserves input.
yield* RefArray.prepend(TodoList, yield* create(text.trim()));
yield* RefSubject.set(TodoText, "");
});
export const editTodo = (id: Domain.TodoId, text: string) =>
text.trim() === ""
? deleteTodo(id)
: RefSubject.update(TodoList, Domain.editText(id, text.trim()));
export const toggleTodoCompleted = (id: Domain.TodoId) =>
RefSubject.update(TodoList, Domain.toggleCompleted(id));
export const deleteTodo = (id: Domain.TodoId) => RefSubject.update(TodoList, Domain.deleteTodo(id));
src/presentation.ts
import { Effect } from "effect";
import { Fx, RefSubject } from "@typed/fx";
import { EventHandler, html, many } from "@typed/template";
import { component } from "@typed/ui/Component";
import * as App from "./application.js";
import * as Domain from "./domain.js";
const TodoItem = component(function* (todo: RefSubject.RefSubject<Domain.Todo>, id: Domain.TodoId) {
// A keyed row owns its draft until that item is hidden or removed.
const editing = yield* RefSubject.make(false);
const draft = yield* RefSubject.make("");
const text = RefSubject.map(todo, (value) => value.text);
const completed = RefSubject.map(todo, (value) => value.completed);
const begin = text.pipe(
Effect.flatMap((value) => RefSubject.set(draft, value)),
Effect.flatMap(() => RefSubject.set(editing, true)),
);
// Only Save writes committed text, so Cancel needs no rollback.
const cancel = RefSubject.set(editing, false);
const save = draft.pipe(
Effect.flatMap((value) => App.editTodo(id, value)),
Effect.flatMap(() => cancel),
);
const editor = Fx.if(editing, {
onTrue: html`<form
class="edit-form"
onsubmit=${EventHandler.make(() => save, { preventDefault: true })}
>
<input
class="edit"
aria-label="Edit todo"
.value=${draft}
oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
RefSubject.set(draft, event.target.value),
)}
onkeydown=${EventHandler.make((event: KeyboardEvent) => (event.key === "Escape" ? cancel : undefined))}
/>
<button type="submit">Save</button>
<button type="button" onclick=${cancel}>Cancel</button>
</form>`,
onFalse: html`<div class="view">
<input
class="toggle"
type="checkbox"
aria-label="Complete ${text}"
?checked=${completed}
onchange=${App.toggleTodoCompleted(id)}
/>
<label ondblclick=${begin}>${text}</label>
<button
class="edit-trigger"
type="button"
aria-label="Edit ${text}"
onclick=${begin}
>
Edit
</button>
<button
class="destroy"
type="button"
aria-label="Delete ${text}"
onclick=${App.deleteTodo(id)}
>
×
</button>
</div>`,
});
return html`<li
class="${Fx.when(completed, { onTrue: "completed", onFalse: "" })} ${Fx.when(editing, { onTrue: "editing", onFalse: "" })}"
>
${editor}
</li>`;
});
// Read browser edits here; the .value binding also reflects resets from the action.
const onInput = EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
RefSubject.set(App.TodoText, event.target.value),
);
// The shell binds existing state and actions; it needs no component-local setup.
export const TodoApp = html`<section class="todoapp">
<header class="header">
<h1>todos</h1>
<form
class="add-todo"
onsubmit=${EventHandler.make(() => App.createTodo, { preventDefault: true })}
>
<input
class="new-todo"
aria-label="New todo"
autocomplete="off"
.value=${App.TodoText}
oninput=${onInput}
placeholder="What needs to be done?"
/>
<button type="submit" class="add-todo-button">Add todo</button>
</form>
</header>
<section class="main">
<ul class="todo-list">
${many(App.TodoList, (todo) => todo.id, TodoItem)}
</ul>
</section>
</section>`;
src/infrastructure.ts
import { DateTime, Effect, Layer } from "effect";
import * as App from "./application.js";
import * as Domain from "./domain.js";
export const Services = Layer.mergeAll(
App.TodoList.make([]),
App.TodoText.make(""),
Layer.succeed(App.CreateTodo, (text: string) =>
Effect.sync((): Domain.Todo => ({
id: Domain.TodoId.make(crypto.randomUUID()),
text,
completed: false,
timestamp: DateTime.makeUnsafe(new Date()),
})),
),
);
src/preview.ts
import { Fx } from "@typed/fx";
import { TodoApp } from "./presentation.js";
import { Services } from "./infrastructure.js";
export const Preview = TodoApp.pipe(Fx.provide(Services));
Changes from Render the application shell2 files
| Before | After | Change | Code |
|---|---|---|---|
| … 17 unchanged lines … | |||
| 18 | 18 | Unchanged | yield* RefArray.prepend(TodoList, yield* create(text.trim())); |
| 19 | 19 | Unchanged | yield* RefSubject.set(TodoText, ""); |
| 20 | 20 | Unchanged | }); |
| 21 | Added | | |
| 22 | Added | export const editTodo = (id: Domain.TodoId, text: string) => | |
| 23 | Added | text.trim() === "" | |
| 24 | Added | ? deleteTodo(id) | |
| 25 | Added | : RefSubject.update(TodoList, Domain.editText(id, text.trim())); | |
| 26 | Added | | |
| 27 | Added | export const toggleTodoCompleted = (id: Domain.TodoId) => | |
| 28 | Added | RefSubject.update(TodoList, Domain.toggleCompleted(id)); | |
| 29 | Added | | |
| 30 | Added | export const deleteTodo = (id: Domain.TodoId) => RefSubject.update(TodoList, Domain.deleteTodo(id)); | |
| Before | After | Change | Code |
|---|---|---|---|
| 1 | Removed | | |
| 2 | Removed | | |
| 1 | Added | import { Effect } from "effect"; | |
| 2 | Added | import { Fx, RefSubject } from "@typed/fx"; | |
| 3 | Added | import { EventHandler, html, many } from "@typed/template"; | |
| 4 | Added | import { component } from "@typed/ui/Component"; | |
| 3 | 5 | Unchanged | import * as App from "./application.js"; |
| 6 | Added | import * as Domain from "./domain.js"; | |
| 4 | 7 | Unchanged | |
| 8 | Added | const TodoItem = component(function* (todo: RefSubject.RefSubject<Domain.Todo>, id: Domain.TodoId) { | |
| 9 | Added | // A keyed row owns its draft until that item is hidden or removed. | |
| 10 | Added | const editing = yield* RefSubject.make(false); | |
| 11 | Added | const draft = yield* RefSubject.make(""); | |
| 12 | Added | const text = RefSubject.map(todo, (value) => value.text); | |
| 13 | Added | const completed = RefSubject.map(todo, (value) => value.completed); | |
| 14 | Added | const begin = text.pipe( | |
| 15 | Added | Effect.flatMap((value) => RefSubject.set(draft, value)), | |
| 16 | Added | Effect.flatMap(() => RefSubject.set(editing, true)), | |
| 17 | Added | ); | |
| 18 | Added | // Only Save writes committed text, so Cancel needs no rollback. | |
| 19 | Added | const cancel = RefSubject.set(editing, false); | |
| 20 | Added | const save = draft.pipe( | |
| 21 | Added | Effect.flatMap((value) => App.editTodo(id, value)), | |
| 22 | Added | Effect.flatMap(() => cancel), | |
| 23 | Added | ); | |
| 24 | Added | const editor = Fx.if(editing, { | |
| 25 | Added | onTrue: html`<form | |
| 26 | Added | class="edit-form" | |
| 27 | Added | onsubmit=${EventHandler.make(() => save, { preventDefault: true })} | |
| 28 | Added | > | |
| 29 | Added | <input | |
| 30 | Added | class="edit" | |
| 31 | Added | aria-label="Edit todo" | |
| 32 | Added | .value=${draft} | |
| 33 | Added | oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) => | |
| 34 | Added | RefSubject.set(draft, event.target.value), | |
| 35 | Added | )} | |
| 36 | Added | onkeydown=${EventHandler.make((event: KeyboardEvent) => (event.key === "Escape" ? cancel : undefined))} | |
| 37 | Added | /> | |
| 38 | Added | <button type="submit">Save</button> | |
| 39 | Added | <button type="button" onclick=${cancel}>Cancel</button> | |
| 40 | Added | </form>`, | |
| 41 | Added | onFalse: html`<div class="view"> | |
| 42 | Added | <input | |
| 43 | Added | class="toggle" | |
| 44 | Added | type="checkbox" | |
| 45 | Added | aria-label="Complete ${text}" | |
| 46 | Added | ?checked=${completed} | |
| 47 | Added | onchange=${App.toggleTodoCompleted(id)} | |
| 48 | Added | /> | |
| 49 | Added | <label ondblclick=${begin}>${text}</label> | |
| 50 | Added | <button | |
| 51 | Added | class="edit-trigger" | |
| 52 | Added | type="button" | |
| 53 | Added | aria-label="Edit ${text}" | |
| 54 | Added | onclick=${begin} | |
| 55 | Added | > | |
| 56 | Added | Edit | |
| 57 | Added | </button> | |
| 58 | Added | <button | |
| 59 | Added | class="destroy" | |
| 60 | Added | type="button" | |
| 61 | Added | aria-label="Delete ${text}" | |
| 62 | Added | onclick=${App.deleteTodo(id)} | |
| 63 | Added | > | |
| 64 | Added | × | |
| 65 | Added | </button> | |
| 66 | Added | </div>`, | |
| 67 | Added | }); | |
| 68 | Added | return html`<li | |
| 69 | Added | class="${Fx.when(completed, { onTrue: "completed", onFalse: "" })} ${Fx.when(editing, { onTrue: "editing", onFalse: "" })}" | |
| 70 | Added | > | |
| 71 | Added | ${editor} | |
| 72 | Added | </li>`; | |
| 73 | Added | }); | |
| 74 | Added | | |
| 5 | 75 | Unchanged | // Read browser edits here; the .value binding also reflects resets from the action. |
| 6 | 76 | Unchanged | const onInput = EventHandler.make((event: InputEvent & { target: HTMLInputElement }) => |
| 7 | 77 | Unchanged | RefSubject.set(App.TodoText, event.target.value), |
| … 17 unchanged lines … | |||
| 25 | 95 | Unchanged | <button type="submit" class="add-todo-button">Add todo</button> |
| 26 | 96 | Unchanged | </form> |
| 27 | 97 | Unchanged | </header> |
| 98 | Added | <section class="main"> | |
| 99 | Added | <ul class="todo-list"> | |
| 100 | Added | ${many(App.TodoList, (todo) => todo.id, TodoItem)} | |
| 101 | Added | </ul> | |
| 102 | Added | </section> | |
| 28 | 103 | Unchanged | </section>`; |