Browse documentation

TUTORIAL / 09

Assemble the application

Join presentation, infrastructure, and the DOM renderer in one composition root.

The interactive example loads in the browser.

The modules describe an application. src/main.ts chooses its services, host, renderer, and running lifetime.

Import the outer pieces

import { Fx } from "@typed/fx";
import { DomRenderTemplate, render } from "@typed/template";
import { Effect, Layer } from "effect";
import { Services } from "./infrastructure.js";
import { TodoApp } from "./presentation.js";

Presentation depends on application contracts, while infrastructure implements those contracts. The entrypoint may know both because connecting them is its job.

Start the application

await render(TodoApp, document.body).pipe(
  Fx.drainLayer,
  Layer.provide([Services, DomRenderTemplate]),
  Layer.launch,
  Effect.runPromise,
);

Read the pipeline in order:

  1. render describes mounting TodoApp into the chosen host.
  2. Fx.drainLayer gives the running render stream a Layer lifetime.
  3. Layer.provide supplies application services and the DOM renderer.
  4. Layer.launch keeps the application running, and Effect.runPromise starts it from the JavaScript entrypoint.

Keep mounting here. Importing TodoApp in a test should not start a second application.

Compare the embedded entry

import { Fx } from "@typed/fx";
import { TodoApp } from "./presentation.js";
import { ServerRouter } from "@typed/router";
import { makeServices } from "./infrastructure.js";

// A private history lets the embedded app navigate without leaving this lesson.
export const Preview = TodoApp.pipe(

The preview imports the same component and service implementation. It selects a private router because its filters belong to the embedded app. Your browser entry uses the browser router.

Try the complete flow: create two todos, save an edit, complete that item, select Completed, clear it, return to All, then reload. The other item should remain. Missing-service types point to assembly; duplicate event handling can indicate that the same host was mounted twice without closing the first lifetime.

The complete files below are a checkpoint if you joined midway. Next, turn the identity and state guarantees into tests of these actual modules.

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 FilterState extends RefSubject.Service<FilterState, Domain.FilterState>()(
  "FilterState",
) {}
export class TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {}
export class CreateTodo extends Context.Service<
  CreateTodo,
  (text: string) => Effect.Effect<Domain.Todo>
>()("CreateTodo") {}

// Filtering changes the view, leaving hidden items in the committed list.
export const Todos = RefSubject.map(
  RefSubject.struct({ list: TodoList, state: FilterState }),
  Domain.filterTodoList,
);

// 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";
import { Link } from "@typed/ui/Link";

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.Todos, (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>
      <ul class="filters">
        ${Domain.FilterState.literals.map(
          (filter) =>
            html`<li>
            ${Link({
              href: filter === "all" ? "/" : "/" + filter,
              content: filter[0]!.toUpperCase() + filter.slice(1),
              class: Fx.when(
                RefSubject.map(App.FilterState, (current) => current === filter),
                { onTrue: "selected", onFalse: "" },
              ),
            })}
          </li>`,
        )}
      </ul>
      ${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, Context, Schema } from "effect";
import { Fx } from "@typed/fx";
import * as Router from "@typed/router";
import * as App from "./application.js";
import * as Domain from "./domain.js";

const TODOS_STORAGE_KEY = `@typed/tutorial/todo-9`;

// Parsed JSON is not yet a TodoList; decode at the storage boundary.
const TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList));
const decodeTodoList = Schema.decodeEffect(TodoListJson);
const encodeTodoList = Schema.encodeEffect(TodoListJson);

class Todos extends Context.Service<
  Todos,
  {
    readonly load: Effect.Effect<Domain.TodoList, unknown>;
    readonly save: (todos: Domain.TodoList) => Effect.Effect<void, unknown>;
  }
>()("TodosService") {
  static readonly get = Todos.pipe(
    Effect.flatMap((service) => service.load),
    Effect.catchCause(() => Effect.succeed([])),
  );

  static readonly set = (todos: Domain.TodoList) =>
    Effect.flatMap(Todos, (service) => service.save(todos)).pipe(
      Effect.catchCause((cause) =>
        Effect.logError("Failed to write todos to key value store", cause),
      ),
    );

  static readonly replicateToStorage = App.TodoList.pipe(Fx.observeLayer(Todos.set));

  static readonly local = Layer.succeed(Todos, {
    load: Effect.try(() => localStorage.getItem(TODOS_STORAGE_KEY)).pipe(
      Effect.flatMap((value) =>
        value === null ? Effect.succeed<Domain.TodoList>([]) : decodeTodoList(value),
      ),
    ),
    save: (todos) =>
      encodeTodoList(todos).pipe(
        Effect.flatMap((value) => Effect.try(() => localStorage.setItem(TODOS_STORAGE_KEY, value))),
      ),
  });
}

const FilterState = Router.match(Router.Slash, "all")
  .match(Router.Parse("active"), "active")
  .match(Router.Parse("completed"), "completed")
  .redirectTo("/")
  .pipe(
    Fx.catchCause(() => Fx.succeed("all" as const)),
  );

// Load before observing changes, so an empty default cannot overwrite saved items.
const Model = Layer.mergeAll(
  App.TodoList.make(Todos.get),
  App.FilterState.make(FilterState),
  App.TodoText.make(""),
);

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, Todos.replicateToStorage).pipe(
    Layer.provideMerge(Model),
    Layer.provideMerge([Todos.local, router]),
  );

export const Services = makeServices();
src/main.ts
import { Fx } from "@typed/fx";
import { DomRenderTemplate, render } from "@typed/template";
import { Effect, Layer } from "effect";
import { Services } from "./infrastructure.js";
import { TodoApp } from "./presentation.js";

await render(TodoApp, document.body).pipe(
  Fx.drainLayer,
  Layer.provide([Services, DomRenderTemplate]),
  Layer.launch,
  Effect.runPromise,
);
src/preview.ts
import { Fx } from "@typed/fx";
import { TodoApp } from "./presentation.js";
import { ServerRouter } from "@typed/router";
import { makeServices } from "./infrastructure.js";

// A private history lets the embedded app navigate without leaving this lesson.
export const Preview = TodoApp.pipe(
  Fx.provide(makeServices(ServerRouter({ url: "https://tutorial.local/" }))),
);
Changes from Persist the list1 file
src/infrastructure.ts
BeforeAfterChangeCode
… 3 unchanged lines …
44Unchangedimport * as App from "./application.js";
55Unchangedimport * as Domain from "./domain.js";
66Unchanged
7Removedconst TODOS_STORAGE_KEY = `@typed/tutorial/todo-8`;
7Addedconst TODOS_STORAGE_KEY = `@typed/tutorial/todo-9`;
88Unchanged
99Unchanged// Parsed JSON is not yet a TodoList; decode at the storage boundary.
1010Unchangedconst TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList));
… 70 unchanged lines …