The interactive example loads in the browser.
The interactive example loads in the browser.
You have been running src/main.ts since the shell chapter. Now follow how it connects the completed modules: services, host, renderer and running lifetime. The diagram above shows the application boundaries.
Import the outer pieces
import "./styles.css";
import { Effect, Layer } from "effect";
import { Fx } from "@typed/fx";
import { DomRenderTemplate, render } from "@typed/template";
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:
renderdescribes mountingTodoAppinto the chosen host.Fx.drainLayergives the running render stream a Layer lifetime.Layer.providesupplies application services and the DOM renderer. The factory comes from the canonical infrastructure module.Layer.launchkeeps the application running, andEffect.runPromisestarts it from the JavaScript entrypoint.
Keep mounting here. Importing TodoApp in a test should not start a second application.
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, add a presentation test for retained row identity and cancelled edits to the domain and application tests.
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";
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 TodoJson = typeof Todo.Encoded;
export type Todo = typeof Todo.Type;
export const TodoList = Schema.Array(Todo);
export type TodoListJson = typeof TodoList.Encoded;
export type TodoList = typeof TodoList.Type;
export const FilterState = Schema.Literals(["all", "active", "completed"]);
export type FilterState = typeof FilterState.Type;
export function updateTodo(list: TodoList, id: TodoId, f: (todo: Todo) => Todo): TodoList {
return list.map((todo) => (todo.id === id ? f(todo) : todo));
}
export function editText(id: TodoId, text: string) {
return (list: TodoList): TodoList => updateTodo(list, id, (todo) => ({ ...todo, text }));
}
export function toggleCompleted(id: TodoId) {
return (list: TodoList): TodoList =>
updateTodo(list, id, (todo) => ({ ...todo, completed: !todo.completed }));
}
export function isCompleted(todo: Todo): boolean {
return todo.completed;
}
export function isActive(todo: Todo): boolean {
return !todo.completed;
}
export function toggleAllCompleted(list: TodoList): TodoList {
if (list.some(isActive)) {
return list.map((todo) => ({ ...todo, completed: true }));
} else {
return list.map((todo) => ({ ...todo, completed: false }));
}
}
export function deleteTodo(id: TodoId) {
return (list: TodoList): TodoList => list.filter((todo) => todo.id !== id);
}
export function clearCompleted(list: TodoList): TodoList {
return list.filter(isActive);
}
export function activeCount(list: TodoList): number {
return list.filter(isActive).length;
}
export function completedCount(list: TodoList): number {
return list.filter(isCompleted).length;
}
export function allAreCompleted(list: TodoList): boolean {
return list.length > 0 && list.every(isCompleted);
}
export function someAreCompleted(list: TodoList): boolean {
return list.some(isCompleted);
}
export function filterTodoList({ list, state }: { list: TodoList; state: FilterState }): TodoList {
switch (state) {
case "all":
return list;
case "active":
return list.filter(isActive);
case "completed":
return list.filter(isCompleted);
}
}
export function updateText(text: string) {
return (todo: Todo): Todo => ({ ...todo, text });
}
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/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/main.ts
import "./styles.css";
import { Effect, Layer } from "effect";
import { Fx } from "@typed/fx";
import { DomRenderTemplate, render } from "@typed/template";
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,
);
Optional website embedding: src/preview.ts
The website preview imports the completed component and services directly from examples/todomvc, with the private router described earlier. The standalone app continues to use src/main.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/" }))),
);