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, double-click one label to edit it, 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:

${many(App.Todos, (todo) => todo.id, TodoItem)}

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) {
  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)),
);
const cancel = RefSubject.set(editing, false);
const save = draft.pipe(
  Effect.flatMap((value) => App.editTodo(id, value)),
  Effect.flatMap(() => cancel),
);

return html`<li

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 rejects whitespace-only text by deleting that Todo; other text is saved as entered. That policy stays outside the row’s browser handlers.

Bind the editor to the draft

  .value=${draft}
  oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
    RefSubject.set(draft, event.target.value),
  )}
  onblur=${EventHandler.make(
    () => Effect.flatMap(editing, (isEditing) => (isEditing ? save : Effect.void)),
    { capture: true },
  )}
  onkeydown=${EventHandler.make((event: KeyboardEvent) => {
    if (event.key === "Escape") return cancel;
    if (event.key === "Enter") return save;

    return undefined;
  })}
/>

The ordinary view and editor remain in the row. The editing class controls their visibility. Enter or blur saves the draft; Escape cancels. Double-clicking the label begins editing.

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";

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 TodoJson = typeof Todo.Encoded;
export type Todo = typeof Todo.Type;

export const TodoList = Schema.Array(Todo);
export type TodoListJson = typeof TodoList.Encoded;
export type TodoList = typeof TodoList.Type;

export const FilterState = Schema.Literals(["all", "active", "completed"]);
export type FilterState = typeof FilterState.Type;

export function updateTodo(list: TodoList, id: TodoId, f: (todo: Todo) => Todo): TodoList {
  return list.map((todo) => (todo.id === id ? f(todo) : todo));
}

export function editText(id: TodoId, text: string) {
  return (list: TodoList): TodoList => updateTodo(list, id, (todo) => ({ ...todo, text }));
}

export function toggleCompleted(id: TodoId) {
  return (list: TodoList): TodoList =>
    updateTodo(list, id, (todo) => ({ ...todo, completed: !todo.completed }));
}

export function isCompleted(todo: Todo): boolean {
  return todo.completed;
}

export function isActive(todo: Todo): boolean {
  return !todo.completed;
}

export function toggleAllCompleted(list: TodoList): TodoList {
  if (list.some(isActive)) {
    return list.map((todo) => ({ ...todo, completed: true }));
  } else {
    return list.map((todo) => ({ ...todo, completed: false }));
  }
}

export function deleteTodo(id: TodoId) {
  return (list: TodoList): TodoList => list.filter((todo) => todo.id !== id);
}

export function clearCompleted(list: TodoList): TodoList {
  return list.filter(isActive);
}

export function activeCount(list: TodoList): number {
  return list.filter(isActive).length;
}

export function completedCount(list: TodoList): number {
  return list.filter(isCompleted).length;
}

export function allAreCompleted(list: TodoList): boolean {
  return list.length > 0 && list.every(isCompleted);
}

export function someAreCompleted(list: TodoList): boolean {
  return list.some(isCompleted);
}

export function filterTodoList({ list, state }: { list: TodoList; state: FilterState }): TodoList {
  switch (state) {
    case "all":
      return list;
    case "active":
      return list.filter(isActive);
    case "completed":
      return list.filter(isCompleted);
  }
}

export function updateText(text: string) {
  return (todo: Todo): Todo => ({ ...todo, text });
}
src/application.ts
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Context from "effect/Context";
import { RefArray, RefSubject } from "@typed/fx";
import * as Domain from "./domain.js";

export class CreateTodo extends Context.Service<
  CreateTodo,
  (text: string) => Effect.Effect<Domain.Todo>
>()("CreateTodo") {
  static readonly call = (text: string) =>
    Effect.flatMap(CreateTodo, (createTodo) => createTodo(text));
}

export class TodoList extends RefSubject.Service<TodoList, Domain.TodoList>()("TodoList") {}

export class FilterState extends RefSubject.Service<FilterState, Domain.FilterState>()(
  "FilterState",
) {}

export class TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {}

export const Todos: RefSubject.Computed<Domain.TodoList, never, TodoList | FilterState> =
  RefSubject.map(
    RefSubject.struct({
      list: TodoList,
      state: FilterState,
    }),
    Domain.filterTodoList,
  );

export const createTodo: Effect.Effect<
  Option.Option<Domain.Todo>,
  never,
  CreateTodo | TodoList | TodoText
> = Effect.flatMap(TodoText, (text: string) =>
  text.trim() === ""
    ? Effect.succeed(Option.none<Domain.Todo>())
    : CreateTodo.call(text).pipe(
        Effect.tap((todo) => RefArray.prepend(TodoList, todo)),
        Effect.tap(() => RefSubject.set(TodoText, "")),
        Effect.asSome,
      ),
);

export const editTodo = (
  id: Domain.TodoId,
  text: string,
): Effect.Effect<Domain.TodoList, never, TodoList> =>
  text.trim() === "" ? deleteTodo(id) : RefSubject.update(TodoList, Domain.editText(id, text));

export const toggleTodoCompleted: (
  id: Domain.TodoId,
) => Effect.Effect<Domain.TodoList, never, TodoList> = (id) =>
  RefSubject.update(TodoList, Domain.toggleCompleted(id));

export const deleteTodo: (id: Domain.TodoId) => Effect.Effect<Domain.TodoList, never, TodoList> = (
  id,
) => RefSubject.update(TodoList, Domain.deleteTodo(id));
src/presentation.ts
// oxlint-disable require-yield
import { Effect } from "effect";
import { Fx, RefSubject } from "@typed/fx";
import { component, EventHandler, html, many } from "@typed/template";
import * as App from "./application.js";
import * as Domain from "./domain.js";


const HasTodos = RefSubject.map(App.TodoList, (list) => list.length > 0).pipe(Fx.skipRepeats);

const TodoItem = component(function* (todo: RefSubject.RefSubject<Domain.Todo>, id: Domain.TodoId) {
  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)),
  );
  const cancel = RefSubject.set(editing, false);
  const save = draft.pipe(
    Effect.flatMap((value) => App.editTodo(id, value)),
    Effect.flatMap(() => cancel),
  );

  return html`<li
    class="${Fx.when(completed, { onTrue: "completed", onFalse: "" })} ${Fx.when(editing, { onTrue: "editing", onFalse: "" })}"
  >
    <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="destroy"
        type="button"
        aria-label="Delete ${text}"
        onclick=${App.deleteTodo(id)}
      ></button>
    </div>
    <input
      class="edit"
      aria-label="Edit todo"
      .value=${draft}
      oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
        RefSubject.set(draft, event.target.value),
      )}
      onblur=${EventHandler.make(
        () => Effect.flatMap(editing, (isEditing) => (isEditing ? save : Effect.void)),
        { capture: true },
      )}
      onkeydown=${EventHandler.make((event: KeyboardEvent) => {
        if (event.key === "Escape") return cancel;
        if (event.key === "Enter") return save;

        return undefined;
      })}
    />
  </li>`;
});

const onInput = EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
  RefSubject.set(App.TodoText, event.target.value),
);

const onNewTodoKeydown = EventHandler.make((event: KeyboardEvent) =>
  event.key === "Enter" ? App.createTodo : undefined,
);

export const TodoApp = html`<section class="todoapp">
  <header class="header">
    <h1>todos</h1>
    <input
      class="new-todo"
      aria-label="New todo"
      autofocus
      autocomplete="off"
      .value=${App.TodoText}
      oninput=${onInput}
      onkeydown=${onNewTodoKeydown}
      placeholder="What needs to be done?"
    />
  </header>
  ${Fx.if(HasTodos, {
    onTrue: html`<section class="main">
      <ul class="todo-list">
        ${many(App.Todos, (todo) => todo.id, TodoItem)}
      </ul>
    </section>
`,
    onFalse: Fx.null,
  })}
</section>
<footer class="info">
  <p>Double-click to edit a todo</p>
  <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
</footer>`;
src/infrastructure.ts
import { DateTime, Effect, Layer } from "effect";

import * as Router from "@typed/router";
import * as App from "./application.js";
import * as Domain from "./domain.js";

const CreateTodo = Layer.sync(
  App.CreateTodo,
  () => (text: string) =>
    Effect.sync((): Domain.Todo => ({
      id: Domain.TodoId.make(crypto.randomUUID()),
      text,
      completed: false,
      timestamp: DateTime.makeUnsafe(new Date()),
    })),
);

export const makeServices = (router = Router.BrowserRouter()) =>
  Layer.mergeAll(
    CreateTodo,
    App.TodoList.make([]),
    App.TodoText.make(""),
    App.FilterState.make("all"),
  ).pipe(Layer.provideMerge(router));

export const Services = makeServices();
src/preview.ts
import { Fx } from "@typed/fx";
import { ServerRouter } from "@typed/router";
import { TodoApp } from "./presentation.js";
import { makeServices } from "./infrastructure.js";

// Only the runtime router changes when this example is embedded in the website.
export const Preview = TodoApp.pipe(
  Fx.provide(makeServices(ServerRouter({ url: "https://tutorial.local/" }))),
);
Changes from Render the application shell3 files
src/application.ts
BeforeAfterChangeCode
… 13 unchanged lines …
1414Unchanged
1515Unchangedexport class TodoList extends RefSubject.Service<TodoList, Domain.TodoList>()("TodoList") {}
1616Unchanged
17Addedexport class FilterState extends RefSubject.Service<FilterState, Domain.FilterState>()(
18Added "FilterState",
19Added) {}
20Added
1721Unchangedexport class TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {}
1822Unchanged
23Addedexport const Todos: RefSubject.Computed<Domain.TodoList, never, TodoList | FilterState> =
24Added RefSubject.map(
25Added RefSubject.struct({
26Added list: TodoList,
27Added state: FilterState,
28Added }),
29Added Domain.filterTodoList,
30Added );
31Added
1932Unchangedexport const createTodo: Effect.Effect<
2033Unchanged Option.Option<Domain.Todo>,
2134Unchanged never,
… 7 unchanged lines …
2942Unchanged Effect.asSome,
3043Unchanged ),
3144Unchanged);
45Added
46Addedexport const editTodo = (
47Added id: Domain.TodoId,
48Added text: string,
49Added): Effect.Effect<Domain.TodoList, never, TodoList> =>
50Added text.trim() === "" ? deleteTodo(id) : RefSubject.update(TodoList, Domain.editText(id, text));
51Added
52Addedexport const toggleTodoCompleted: (
53Added id: Domain.TodoId,
54Added) => Effect.Effect<Domain.TodoList, never, TodoList> = (id) =>
55Added RefSubject.update(TodoList, Domain.toggleCompleted(id));
56Added
57Addedexport const deleteTodo: (id: Domain.TodoId) => Effect.Effect<Domain.TodoList, never, TodoList> = (
58Added id,
59Added) => RefSubject.update(TodoList, Domain.deleteTodo(id));
src/presentation.ts
BeforeAfterChangeCode
11Unchanged// oxlint-disable require-yield
2Removed
3Removedimport { RefSubject } from "@typed/fx";
4Removedimport { EventHandler, html } from "@typed/template";
2Addedimport { Effect } from "effect";
3Addedimport { Fx, RefSubject } from "@typed/fx";
4Addedimport { component, EventHandler, html, many } from "@typed/template";
55Unchangedimport * as App from "./application.js";
6Addedimport * as Domain from "./domain.js";
67Unchanged
78Unchanged
9Addedconst HasTodos = RefSubject.map(App.TodoList, (list) => list.length > 0).pipe(Fx.skipRepeats);
810Unchanged
11Addedconst TodoItem = component(function* (todo: RefSubject.RefSubject<Domain.Todo>, id: Domain.TodoId) {
12Added const editing = yield* RefSubject.make(false);
13Added const draft = yield* RefSubject.make("");
14Added
15Added const text = RefSubject.map(todo, (value) => value.text);
16Added const completed = RefSubject.map(todo, (value) => value.completed);
17Added
18Added const begin = text.pipe(
19Added Effect.flatMap((value) => RefSubject.set(draft, value)),
20Added Effect.flatMap(() => RefSubject.set(editing, true)),
21Added );
22Added const cancel = RefSubject.set(editing, false);
23Added const save = draft.pipe(
24Added Effect.flatMap((value) => App.editTodo(id, value)),
25Added Effect.flatMap(() => cancel),
26Added );
27Added
28Added return html`<li
29Added class="${Fx.when(completed, { onTrue: "completed", onFalse: "" })} ${Fx.when(editing, { onTrue: "editing", onFalse: "" })}"
30Added >
31Added <div class="view">
32Added <input
33Added class="toggle"
34Added type="checkbox"
35Added aria-label="Complete ${text}"
36Added ?checked=${completed}
37Added onchange=${App.toggleTodoCompleted(id)}
38Added />
39Added <label ondblclick=${begin}>${text}</label>
40Added <button
41Added class="destroy"
42Added type="button"
43Added aria-label="Delete ${text}"
44Added onclick=${App.deleteTodo(id)}
45Added ></button>
46Added </div>
47Added <input
48Added class="edit"
49Added aria-label="Edit todo"
50Added .value=${draft}
51Added oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
52Added RefSubject.set(draft, event.target.value),
53Added )}
54Added onblur=${EventHandler.make(
55Added () => Effect.flatMap(editing, (isEditing) => (isEditing ? save : Effect.void)),
56Added { capture: true },
57Added )}
58Added onkeydown=${EventHandler.make((event: KeyboardEvent) => {
59Added if (event.key === "Escape") return cancel;
60Added if (event.key === "Enter") return save;
61Added
62Added return undefined;
63Added })}
64Added />
65Added </li>`;
66Added});
67Added
968Unchangedconst onInput = EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
1069Unchanged RefSubject.set(App.TodoText, event.target.value),
1170Unchanged);
… 16 unchanged lines …
2887Unchanged placeholder="What needs to be done?"
2988Unchanged />
3089Unchanged </header>
90Added ${Fx.if(HasTodos, {
91Added onTrue: html`<section class="main">
92Added <ul class="todo-list">
93Added ${many(App.Todos, (todo) => todo.id, TodoItem)}
94Added </ul>
95Added </section>
96Added`,
97Added onFalse: Fx.null,
98Added })}
3199Unchanged</section>
32100Unchanged<footer class="info">
33101Unchanged <p>Double-click to edit a todo</p>
… 2 unchanged lines …
src/infrastructure.ts
BeforeAfterChangeCode
… 19 unchanged lines …
2020Unchanged CreateTodo,
2121Unchanged App.TodoList.make([]),
2222Unchanged App.TodoText.make(""),
23Added App.FilterState.make("all"),
2324Unchanged ).pipe(Layer.provideMerge(router));
2425Unchanged
2526Unchangedexport const Services = makeServices();