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

export const ActiveCount: RefSubject.Computed<number, never, TodoList> = RefSubject.map(
  TodoList,
  Domain.activeCount,
);

export const SomeAreCompleted: RefSubject.Computed<boolean, never, TodoList> = RefSubject.map(
  TodoList,
  Domain.someAreCompleted,
);

export const AllAreCompleted: RefSubject.Computed<boolean, never, TodoList> = 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.

Render the count in src/presentation.ts

<span class="todo-count"><strong>${App.ActiveCount}</strong>
  ${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.

Handle the empty list

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

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

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.

The main section and footer also follow the canonical non-empty condition. The named HasTodos view suppresses repeated boolean values.

const HasTodos = RefSubject.map(App.TodoList, (list) => list.length > 0).pipe(Fx.skipRepeats);
${Fx.if(HasTodos, {
  onTrue: html`<section class="main">

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 * 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 ActiveCount: RefSubject.Computed<number, never, TodoList> = RefSubject.map(
  TodoList,
  Domain.activeCount,
);

export const SomeAreCompleted: RefSubject.Computed<boolean, never, TodoList> = RefSubject.map(
  TodoList,
  Domain.someAreCompleted,
);

export const AllAreCompleted: RefSubject.Computed<boolean, never, TodoList> = RefSubject.map(
  TodoList,
  Domain.allAreCompleted,
);

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

export const clearCompletedTodos: Effect.Effect<Domain.TodoList, never, TodoList> =
  RefSubject.update(TodoList, Domain.clearCompleted);

export const toggleAllCompleted: Effect.Effect<Domain.TodoList, never, TodoList> =
  RefSubject.update(TodoList, Domain.toggleAllCompleted);
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">
      <input
        id="toggle-all"
        class="toggle-all"
        type="checkbox"
        ?checked=${App.AllAreCompleted}
        onchange=${App.toggleAllCompleted}
      />
      <label for="toggle-all">Mark all as complete</label>
      <ul class="todo-list">
        ${many(App.Todos, (todo) => todo.id, TodoItem)}
      </ul>
    </section>
    <footer class="footer">
      <span class="todo-count"><strong>${App.ActiveCount}</strong>
        ${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>`,
    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 keyed Todo items2 files
src/application.ts
BeforeAfterChangeCode
… 28 unchanged lines …
2929Unchanged Domain.filterTodoList,
3030Unchanged );
3131Unchanged
32Addedexport const ActiveCount: RefSubject.Computed<number, never, TodoList> = RefSubject.map(
33Added TodoList,
34Added Domain.activeCount,
35Added);
36Added
37Addedexport const SomeAreCompleted: RefSubject.Computed<boolean, never, TodoList> = RefSubject.map(
38Added TodoList,
39Added Domain.someAreCompleted,
40Added);
41Added
42Addedexport const AllAreCompleted: RefSubject.Computed<boolean, never, TodoList> = RefSubject.map(
43Added TodoList,
44Added Domain.allAreCompleted,
45Added);
46Added
3247Unchangedexport const createTodo: Effect.Effect<
3348Unchanged Option.Option<Domain.Todo>,
3449Unchanged never,
… 22 unchanged lines …
5772Unchangedexport const deleteTodo: (id: Domain.TodoId) => Effect.Effect<Domain.TodoList, never, TodoList> = (
5873Unchanged id,
5974Unchanged) => RefSubject.update(TodoList, Domain.deleteTodo(id));
75Added
76Addedexport const clearCompletedTodos: Effect.Effect<Domain.TodoList, never, TodoList> =
77Added RefSubject.update(TodoList, Domain.clearCompleted);
78Added
79Addedexport const toggleAllCompleted: Effect.Effect<Domain.TodoList, never, TodoList> =
80Added RefSubject.update(TodoList, Domain.toggleAllCompleted);
src/presentation.ts
BeforeAfterChangeCode
… 88 unchanged lines …
8989Unchanged </header>
9090Unchanged ${Fx.if(HasTodos, {
9191Unchanged onTrue: html`<section class="main">
92Added <input
93Added id="toggle-all"
94Added class="toggle-all"
95Added type="checkbox"
96Added ?checked=${App.AllAreCompleted}
97Added onchange=${App.toggleAllCompleted}
98Added />
99Added <label for="toggle-all">Mark all as complete</label>
92100Unchanged <ul class="todo-list">
93101Unchanged ${many(App.Todos, (todo) => todo.id, TodoItem)}
94102Unchanged </ul>
95103Unchanged </section>
96Removed`,
104Added <footer class="footer">
105Added <span class="todo-count"><strong>${App.ActiveCount}</strong>
106Added ${RefSubject.map(App.ActiveCount, (count) => (count === 1 ? " item" : " items"))} left</span>
107Added ${Fx.if(App.SomeAreCompleted, {
108Added onTrue: html`<button
109Added class="clear-completed"
110Added type="button"
111Added onclick=${App.clearCompletedTodos}
112Added >
113Added Clear completed
114Added </button>`,
115Added onFalse: Fx.null,
116Added })}
117Added </footer>`,
97118Unchanged onFalse: Fx.null,
98119Unchanged })}
99120Unchanged</section>
… 4 unchanged lines …