Browse documentation

TUTORIAL / 08

Persist the list

Observe application state from a local-storage adapter.

The interactive example loads in the browser.

Create an item and reload this page. It should return. We will load the initial list from storage and observe later changes, without changing the create or edit actions.

Decode storage in src/infrastructure.ts

const TODOS_STORAGE_KEY = `@typed/todomvc/todos`;

const TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList));
const decodeTodoList = Schema.decodeEffect(TodoListJson);
const encodeTodoList = Schema.encodeEffect(TodoListJson);

Storage holds a string. This codec checks the Todo fields and converts timestamps between stored strings and domain values; JSON.parse alone would not establish that contract.

The Todos service exposes load and save. Its local implementation handles the browser boundary:

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

No saved string means an empty list. Browser operations and codec work stay inside Effects, where the adapter can handle failure.

Choose the load-failure policy

static readonly get = Todos.pipe(
  Effect.flatMap((service) => service.load),
  Effect.catchCause(() => Effect.succeed([])),
);

This small example falls back to an empty list for invalid data or unavailable storage. That is a product choice: it can overwrite corrupt data with a later save. An application that must recover records should retain the original string and show a warning instead.

Initialize before observing

const Model = Layer.mergeAll(
  App.TodoList.make(Todos.get),
  App.FilterState.make(FilterState),
  App.TodoText.make(""),

The initializer is lazy: creating the subject does not load storage. Its first read or subscription runs the load Effect, so its first value is the decoded result. The observer persists that value and subsequent changes:

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

Todos.set logs write failures and leaves the in-memory app usable. Load failure and save failure have different consequences; do not hide either in a button handler.

Try it: create and complete an item, reload, and switch filters. Committed todos persist; an unfinished input does not. Inspect @typed/todomvc/todos in browser storage to see the encoded value. If valid data disappears, check decoding and initialization before changing the renderer.

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/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/todomvc/todos`;

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

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

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>
      <ul class="filters">
        ${Domain.FilterState.literals.map(
          (filter) =>
            html`<li>
              ${Link({
                href: filter === "all" ? "/" : "/" + filter,
                content: filter[0]!.toUpperCase() + filter.slice(1),
                props: {
                  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>`,
    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/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 Route the filter1 file
src/infrastructure.ts
BeforeAfterChangeCode
1Removedimport { DateTime, Effect, Layer } from "effect";
1Addedimport { DateTime, Effect, Layer, Context, Schema } from "effect";
22Unchangedimport { Fx } from "@typed/fx";
33Unchangedimport * as Router from "@typed/router";
44Unchangedimport * as App from "./application.js";
55Unchangedimport * as Domain from "./domain.js";
66Unchanged
7Addedconst TODOS_STORAGE_KEY = `@typed/todomvc/todos`;
8Added
9Addedconst TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList));
10Addedconst decodeTodoList = Schema.decodeEffect(TodoListJson);
11Addedconst encodeTodoList = Schema.encodeEffect(TodoListJson);
12Added
13Addedclass Todos extends Context.Service<
14Added Todos,
15Added {
16Added readonly load: Effect.Effect<Domain.TodoList, unknown>;
17Added readonly save: (todos: Domain.TodoList) => Effect.Effect<void, unknown>;
18Added }
19Added>()("TodosService") {
20Added static readonly get = Todos.pipe(
21Added Effect.flatMap((service) => service.load),
22Added Effect.catchCause(() => Effect.succeed([])),
23Added );
24Added
25Added static readonly set = (todos: Domain.TodoList) =>
26Added Effect.flatMap(Todos, (service) => service.save(todos)).pipe(
27Added Effect.catchCause((cause) =>
28Added Effect.logError("Failed to write todos to key value store", cause),
29Added ),
30Added );
31Added
32Added static readonly replicateToStorage = App.TodoList.pipe(Fx.observeLayer(Todos.set));
33Added
34Added static readonly local = Layer.succeed(Todos, {
35Added load: Effect.try(() => localStorage.getItem(TODOS_STORAGE_KEY)).pipe(
36Added Effect.flatMap((value) =>
37Added value === null ? Effect.succeed<Domain.TodoList>([]) : decodeTodoList(value),
38Added ),
39Added ),
40Added save: (todos) =>
41Added encodeTodoList(todos).pipe(
42Added Effect.flatMap((value) => Effect.try(() => localStorage.setItem(TODOS_STORAGE_KEY, value))),
43Added ),
44Added });
45Added}
46Added
747Unchangedconst FilterState = Router.match(Router.Slash, "all")
848Unchanged .match(Router.Parse("active"), "active")
949Unchanged .match(Router.Parse("completed"), "completed")
… 2 unchanged lines …
1252Unchanged Fx.catchCause(() => Fx.succeed("all" as const)),
1353Unchanged );
1454Unchanged
55Addedconst Model = Layer.mergeAll(
56Added App.TodoList.make(Todos.get),
57Added App.FilterState.make(FilterState),
58Added App.TodoText.make(""),
59Added);
60Added
1561Unchangedconst CreateTodo = Layer.sync(
1662Unchanged App.CreateTodo,
1763Unchanged () => (text: string) =>
… 6 unchanged lines …
2470Unchanged);
2571Unchanged
2672Unchangedexport const makeServices = (router = Router.BrowserRouter()) =>
27Removed Layer.mergeAll(
28Removed CreateTodo,
29Removed App.TodoList.make([]),
30Removed App.TodoText.make(""),
31Removed App.FilterState.make(FilterState),
32Removed ).pipe(Layer.provideMerge(router));
73Added Layer.mergeAll(CreateTodo, Todos.replicateToStorage).pipe(
74Added Layer.provideMerge(Model),
75Added Layer.provideMerge([Todos.local, router]),
76Added );
3377Unchanged
3478Unchangedexport const Services = makeServices();