Browse documentation

TUTORIAL / 06

Derive the footer

Compute counts and conditional controls from the model.

The interactive example loads in the browser.

Complete one of two items in the preview. The count should become one, and Clear completed should appear. Both follow the list without a second mutable counter.

Derive the answers in src/application.ts

// Count the full list even when the current filter hides some rows.
export const ActiveCount = RefSubject.map(TodoList, Domain.activeCount);
export const SomeAreCompleted = RefSubject.map(TodoList, Domain.someAreCompleted);
export const AllAreCompleted = RefSubject.map(TodoList, Domain.allAreCompleted);

Every creation, deletion, and toggle changes TodoList. These read views recompute from that authority, so actions do not also have to maintain counts and flags.

The empty list deserves a deliberate rule in src/domain.ts:

// 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 }));
};

every alone would report true for an empty array. The nonempty check keeps Mark all complete unchecked when there are no items. Toggling all completes every item if any is active; otherwise it reopens them.

Render the count in src/presentation.ts

<span class="todo-count">${App.ActiveCount}
  ${RefSubject.map(App.ActiveCount, (count) => (count === 1 ? "item" : "items"))}
  left</span>

The number and its singular or plural label are both live values. The count means active items in the whole list; adding a filter later should not change that meaning.

Show a control only when it can act

${Fx.if(App.SomeAreCompleted, {
  onTrue: html`<button
    class="clear-completed"
    type="button"
    onclick=${App.clearCompletedTodos}
  >
    Clear completed
  </button>`,
  onFalse: Fx.null,
})}

After the last completed item is cleared, the button and its event binding disappear together. The action changes the list; the condition handles its presentation.

Try it: clear one completed item, complete the remaining item, then toggle all. Check the count, clear button, and checkbox after each action. A stale count suggests a separate mutable counter or a one-time read where you intended a live view.

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/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") {}

// Count the full list even when the current filter hides some rows.
export const ActiveCount = RefSubject.map(TodoList, Domain.activeCount);
export const SomeAreCompleted = RefSubject.map(TodoList, Domain.someAreCompleted);
export const AllAreCompleted = RefSubject.map(TodoList, Domain.allAreCompleted);

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

export const clearCompletedTodos = RefSubject.update(TodoList, Domain.clearCompleted);
export const toggleAllCompleted = RefSubject.update(TodoList, Domain.toggleAllCompleted);
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">
    <label class="toggle-all-label"><input
        class="toggle-all"
        type="checkbox"
        ?checked=${App.AllAreCompleted}
        onchange=${App.toggleAllCompleted}
      />
      Mark all complete</label>
    <ul class="todo-list">
      ${many(App.TodoList, (todo) => todo.id, TodoItem)}
    </ul>
    <footer class="footer">
      <span class="todo-count">${App.ActiveCount}
        ${RefSubject.map(App.ActiveCount, (count) => (count === 1 ? "item" : "items"))}
        left</span>
      ${Fx.if(App.SomeAreCompleted, {
        onTrue: html`<button
          class="clear-completed"
          type="button"
          onclick=${App.clearCompletedTodos}
        >
          Clear completed
        </button>`,
        onFalse: Fx.null,
      })}
    </footer>
  </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 keyed Todo items2 files
src/application.ts
BeforeAfterChangeCode
… 9 unchanged lines …
1010Unchanged (text: string) => Effect.Effect<Domain.Todo>
1111Unchanged>()("CreateTodo") {}
1212Unchanged
13Added// Count the full list even when the current filter hides some rows.
14Addedexport const ActiveCount = RefSubject.map(TodoList, Domain.activeCount);
15Addedexport const SomeAreCompleted = RefSubject.map(TodoList, Domain.someAreCompleted);
16Addedexport const AllAreCompleted = RefSubject.map(TodoList, Domain.allAreCompleted);
17Added
1318Unchangedexport const createTodo = Effect.gen(function* () {
1419Unchanged const text = yield* TodoText;
1520Unchanged if (text.trim() === "") return;
… 12 unchanged lines …
2833Unchanged RefSubject.update(TodoList, Domain.toggleCompleted(id));
2934Unchanged
3035Unchangedexport const deleteTodo = (id: Domain.TodoId) => RefSubject.update(TodoList, Domain.deleteTodo(id));
36Added
37Addedexport const clearCompletedTodos = RefSubject.update(TodoList, Domain.clearCompleted);
38Addedexport const toggleAllCompleted = RefSubject.update(TodoList, Domain.toggleAllCompleted);
src/presentation.ts
BeforeAfterChangeCode
… 95 unchanged lines …
9696Unchanged </form>
9797Unchanged </header>
9898Unchanged <section class="main">
99Added <label class="toggle-all-label"><input
100Added class="toggle-all"
101Added type="checkbox"
102Added ?checked=${App.AllAreCompleted}
103Added onchange=${App.toggleAllCompleted}
104Added />
105Added Mark all complete</label>
99106Unchanged <ul class="todo-list">
100107Unchanged ${many(App.TodoList, (todo) => todo.id, TodoItem)}
101108Unchanged </ul>
109Added <footer class="footer">
110Added <span class="todo-count">${App.ActiveCount}
111Added ${RefSubject.map(App.ActiveCount, (count) => (count === 1 ? "item" : "items"))}
112Added left</span>
113Added ${Fx.if(App.SomeAreCompleted, {
114Added onTrue: html`<button
115Added class="clear-completed"
116Added type="button"
117Added onclick=${App.clearCompletedTodos}
118Added >
119Added Clear completed
120Added </button>`,
121Added onFalse: Fx.null,
122Added })}
123Added </footer>
102124Unchanged </section>
103125Unchanged</section>`;