The interactive example loads in the browser.
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/tutorial/todo-8`;
// 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);
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
// 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(""),
);
The subject starts from the decoded load result, rather than publishing an empty default while loading. Its observer then persists the current 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/tutorial/todo-8 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/tutorial/todo-8`;
// 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/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/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 Route the filter1 file
| Before | After | Change | Code |
|---|---|---|---|
| 1 | Removed | | |
| 1 | Added | import { DateTime, Effect, Layer, Context, Schema } from "effect"; | |
| 2 | 2 | Unchanged | import { Fx } from "@typed/fx"; |
| 3 | 3 | Unchanged | import * as Router from "@typed/router"; |
| 4 | 4 | Unchanged | import * as App from "./application.js"; |
| 5 | 5 | Unchanged | import * as Domain from "./domain.js"; |
| 6 | 6 | Unchanged | |
| 7 | Added | const TODOS_STORAGE_KEY = `@typed/tutorial/todo-8`; | |
| 8 | Added | | |
| 9 | Added | // Parsed JSON is not yet a TodoList; decode at the storage boundary. | |
| 10 | Added | const TodoListJson = Schema.fromJsonString(Schema.toCodecJson(Domain.TodoList)); | |
| 11 | Added | const decodeTodoList = Schema.decodeEffect(TodoListJson); | |
| 12 | Added | const encodeTodoList = Schema.encodeEffect(TodoListJson); | |
| 13 | Added | | |
| 14 | Added | class Todos extends Context.Service< | |
| 15 | Added | Todos, | |
| 16 | Added | { | |
| 17 | Added | readonly load: Effect.Effect<Domain.TodoList, unknown>; | |
| 18 | Added | readonly save: (todos: Domain.TodoList) => Effect.Effect<void, unknown>; | |
| 19 | Added | } | |
| 20 | Added | >()("TodosService") { | |
| 21 | Added | static readonly get = Todos.pipe( | |
| 22 | Added | Effect.flatMap((service) => service.load), | |
| 23 | Added | Effect.catchCause(() => Effect.succeed([])), | |
| 24 | Added | ); | |
| 25 | Added | | |
| 26 | Added | static readonly set = (todos: Domain.TodoList) => | |
| 27 | Added | Effect.flatMap(Todos, (service) => service.save(todos)).pipe( | |
| 28 | Added | Effect.catchCause((cause) => | |
| 29 | Added | Effect.logError("Failed to write todos to key value store", cause), | |
| 30 | Added | ), | |
| 31 | Added | ); | |
| 32 | Added | | |
| 33 | Added | static readonly replicateToStorage = App.TodoList.pipe(Fx.observeLayer(Todos.set)); | |
| 34 | Added | | |
| 35 | Added | static readonly local = Layer.succeed(Todos, { | |
| 36 | Added | load: Effect.try(() => localStorage.getItem(TODOS_STORAGE_KEY)).pipe( | |
| 37 | Added | Effect.flatMap((value) => | |
| 38 | Added | value === null ? Effect.succeed<Domain.TodoList>([]) : decodeTodoList(value), | |
| 39 | Added | ), | |
| 40 | Added | ), | |
| 41 | Added | save: (todos) => | |
| 42 | Added | encodeTodoList(todos).pipe( | |
| 43 | Added | Effect.flatMap((value) => Effect.try(() => localStorage.setItem(TODOS_STORAGE_KEY, value))), | |
| 44 | Added | ), | |
| 45 | Added | }); | |
| 46 | Added | } | |
| 47 | Added | | |
| 7 | 48 | Unchanged | const FilterState = Router.match(Router.Slash, "all") |
| 8 | 49 | Unchanged | .match(Router.Parse("active"), "active") |
| 9 | 50 | Unchanged | .match(Router.Parse("completed"), "completed") |
| … 2 unchanged lines … | |||
| 12 | 53 | Unchanged | Fx.catchCause(() => Fx.succeed("all" as const)), |
| 13 | 54 | Unchanged | ); |
| 14 | 55 | Unchanged | |
| 56 | Added | // Load before observing changes, so an empty default cannot overwrite saved items. | |
| 15 | 57 | Unchanged | const Model = Layer.mergeAll( |
| 16 | Removed | | |
| 58 | Added | App.TodoList.make(Todos.get), | |
| 17 | 59 | Unchanged | App.FilterState.make(FilterState), |
| 18 | 60 | Unchanged | App.TodoText.make(""), |
| 19 | 61 | Unchanged | ); |
| … 10 unchanged lines … | |||
| 30 | 72 | Unchanged | ); |
| 31 | 73 | Unchanged | |
| 32 | 74 | Unchanged | export const makeServices = (router = Router.BrowserRouter()) => |
| 33 | Removed | | |
| 75 | Added | Layer.mergeAll(CreateTodo, Todos.replicateToStorage).pipe( | |
| 34 | 76 | Unchanged | Layer.provideMerge(Model), |
| 35 | Removed | | |
| 77 | Added | Layer.provideMerge([Todos.local, router]), | |
| 36 | 78 | Unchanged | ); |
| 37 | 79 | Unchanged | |
| 38 | 80 | Unchanged | export const Services = makeServices(); |