Browse documentation

TUTORIAL / 05

Render keyed Todo items

Preserve item identity while editing, toggling, and deleting.

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
src/application.ts
BeforeAfterChangeCode
… 17 unchanged lines …
1818Unchanged yield* RefArray.prepend(TodoList, yield* create(text.trim()));
1919Unchanged yield* RefSubject.set(TodoText, "");
2020Unchanged});
21Added
22Addedexport const editTodo = (id: Domain.TodoId, text: string) =>
23Added text.trim() === ""
24Added ? deleteTodo(id)
25Added : RefSubject.update(TodoList, Domain.editText(id, text.trim()));
26Added
27Addedexport const toggleTodoCompleted = (id: Domain.TodoId) =>
28Added RefSubject.update(TodoList, Domain.toggleCompleted(id));
29Added
30Addedexport const deleteTodo = (id: Domain.TodoId) => RefSubject.update(TodoList, Domain.deleteTodo(id));
src/presentation.ts
BeforeAfterChangeCode
1Removedimport { RefSubject } from "@typed/fx";
2Removedimport { EventHandler, html } from "@typed/template";
1Addedimport { Effect } from "effect";
2Addedimport { Fx, RefSubject } from "@typed/fx";
3Addedimport { EventHandler, html, many } from "@typed/template";
4Addedimport { component } from "@typed/ui/Component";
35Unchangedimport * as App from "./application.js";
6Addedimport * as Domain from "./domain.js";
47Unchanged
8Addedconst TodoItem = component(function* (todo: RefSubject.RefSubject<Domain.Todo>, id: Domain.TodoId) {
9Added // A keyed row owns its draft until that item is hidden or removed.
10Added const editing = yield* RefSubject.make(false);
11Added const draft = yield* RefSubject.make("");
12Added const text = RefSubject.map(todo, (value) => value.text);
13Added const completed = RefSubject.map(todo, (value) => value.completed);
14Added const begin = text.pipe(
15Added Effect.flatMap((value) => RefSubject.set(draft, value)),
16Added Effect.flatMap(() => RefSubject.set(editing, true)),
17Added );
18Added // Only Save writes committed text, so Cancel needs no rollback.
19Added const cancel = RefSubject.set(editing, false);
20Added const save = draft.pipe(
21Added Effect.flatMap((value) => App.editTodo(id, value)),
22Added Effect.flatMap(() => cancel),
23Added );
24Added const editor = Fx.if(editing, {
25Added onTrue: html`<form
26Added class="edit-form"
27Added onsubmit=${EventHandler.make(() => save, { preventDefault: true })}
28Added >
29Added <input
30Added class="edit"
31Added aria-label="Edit todo"
32Added .value=${draft}
33Added oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
34Added RefSubject.set(draft, event.target.value),
35Added )}
36Added onkeydown=${EventHandler.make((event: KeyboardEvent) => (event.key === "Escape" ? cancel : undefined))}
37Added />
38Added <button type="submit">Save</button>
39Added <button type="button" onclick=${cancel}>Cancel</button>
40Added </form>`,
41Added onFalse: html`<div class="view">
42Added <input
43Added class="toggle"
44Added type="checkbox"
45Added aria-label="Complete ${text}"
46Added ?checked=${completed}
47Added onchange=${App.toggleTodoCompleted(id)}
48Added />
49Added <label ondblclick=${begin}>${text}</label>
50Added <button
51Added class="edit-trigger"
52Added type="button"
53Added aria-label="Edit ${text}"
54Added onclick=${begin}
55Added >
56Added Edit
57Added </button>
58Added <button
59Added class="destroy"
60Added type="button"
61Added aria-label="Delete ${text}"
62Added onclick=${App.deleteTodo(id)}
63Added >
64Added ×
65Added </button>
66Added </div>`,
67Added });
68Added return html`<li
69Added class="${Fx.when(completed, { onTrue: "completed", onFalse: "" })} ${Fx.when(editing, { onTrue: "editing", onFalse: "" })}"
70Added >
71Added ${editor}
72Added </li>`;
73Added});
74Added
575Unchanged// Read browser edits here; the .value binding also reflects resets from the action.
676Unchangedconst onInput = EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
777Unchanged RefSubject.set(App.TodoText, event.target.value),
… 17 unchanged lines …
2595Unchanged <button type="submit" class="add-todo-button">Add todo</button>
2696Unchanged </form>
2797Unchanged </header>
98Added <section class="main">
99Added <ul class="todo-list">
100Added ${many(App.TodoList, (todo) => todo.id, TodoItem)}
101Added </ul>
102Added </section>
28103Unchanged</section>`;