The interactive example loads in the browser.
The interactive example loads in the browser.
Create two items, double-click one label to edit it, then press Escape. The label should keep its original text. We need stable rows and a separate draft to make that work.
Give each row its ID
In src/presentation.ts, the list delegates each item to TodoItem:
${many(App.Todos, (todo) => todo.id, TodoItem)}
many matches rows by Todo ID. Prepending another item can move an existing row without recreating it. Titles and array positions cannot make that promise.
Allocate state inside the row
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);
Here component() earns its place: each mounted row needs its own editing flag and draft. text and completed are read views of its Todo. The outer TodoApp remains a direct html template because it allocates nothing.
Begin, cancel, and save
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
Beginning copies committed text into the draft. Cancelling only hides the editor. Saving sends the draft to App.editTodo before closing it. Typing into committed text directly would leave nothing for Cancel to preserve.
The application action rejects whitespace-only text by deleting that Todo; other text is saved as entered. That policy stays outside the row’s browser handlers.
Bind the editor to the draft
.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;
})}
/>
The ordinary view and editor remain in the row. The editing class controls their visibility. Enter or blur saves the draft; Escape cancels. Double-clicking the label begins editing.
Try it: keep one row in edit mode while adding another above it. Then cancel, edit again, and save. If rows share drafts, check where those subjects were allocated and whether IDs are unique. Removing or filtering out a row ends its rendered lifetime; an unfinished draft is not persisted application data.
Continue with keyed collections for the identity mechanism. Next, derive the footer from the same list.
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 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));
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">
<ul class="todo-list">
${many(App.Todos, (todo) => todo.id, TodoItem)}
</ul>
</section>
`,
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 the application shell3 files
| Before | After | Change | Code |
|---|---|---|---|
| … 13 unchanged lines … | |||
| 14 | 14 | Unchanged | |
| 15 | 15 | Unchanged | export class TodoList extends RefSubject.Service<TodoList, Domain.TodoList>()("TodoList") {} |
| 16 | 16 | Unchanged | |
| 17 | Added | export class FilterState extends RefSubject.Service<FilterState, Domain.FilterState>()( | |
| 18 | Added | "FilterState", | |
| 19 | Added | ) {} | |
| 20 | Added | | |
| 17 | 21 | Unchanged | export class TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {} |
| 18 | 22 | Unchanged | |
| 23 | Added | export const Todos: RefSubject.Computed<Domain.TodoList, never, TodoList | FilterState> = | |
| 24 | Added | RefSubject.map( | |
| 25 | Added | RefSubject.struct({ | |
| 26 | Added | list: TodoList, | |
| 27 | Added | state: FilterState, | |
| 28 | Added | }), | |
| 29 | Added | Domain.filterTodoList, | |
| 30 | Added | ); | |
| 31 | Added | | |
| 19 | 32 | Unchanged | export const createTodo: Effect.Effect< |
| 20 | 33 | Unchanged | Option.Option<Domain.Todo>, |
| 21 | 34 | Unchanged | never, |
| … 7 unchanged lines … | |||
| 29 | 42 | Unchanged | Effect.asSome, |
| 30 | 43 | Unchanged | ), |
| 31 | 44 | Unchanged | ); |
| 45 | Added | | |
| 46 | Added | export const editTodo = ( | |
| 47 | Added | id: Domain.TodoId, | |
| 48 | Added | text: string, | |
| 49 | Added | ): Effect.Effect<Domain.TodoList, never, TodoList> => | |
| 50 | Added | text.trim() === "" ? deleteTodo(id) : RefSubject.update(TodoList, Domain.editText(id, text)); | |
| 51 | Added | | |
| 52 | Added | export const toggleTodoCompleted: ( | |
| 53 | Added | id: Domain.TodoId, | |
| 54 | Added | ) => Effect.Effect<Domain.TodoList, never, TodoList> = (id) => | |
| 55 | Added | RefSubject.update(TodoList, Domain.toggleCompleted(id)); | |
| 56 | Added | | |
| 57 | Added | export const deleteTodo: (id: Domain.TodoId) => Effect.Effect<Domain.TodoList, never, TodoList> = ( | |
| 58 | Added | id, | |
| 59 | Added | ) => RefSubject.update(TodoList, Domain.deleteTodo(id)); | |
| Before | After | Change | Code |
|---|---|---|---|
| 1 | 1 | Unchanged | // oxlint-disable require-yield |
| 2 | Removed | | |
| 3 | Removed | | |
| 4 | Removed | | |
| 2 | Added | import { Effect } from "effect"; | |
| 3 | Added | import { Fx, RefSubject } from "@typed/fx"; | |
| 4 | Added | import { component, EventHandler, html, many } from "@typed/template"; | |
| 5 | 5 | Unchanged | import * as App from "./application.js"; |
| 6 | Added | import * as Domain from "./domain.js"; | |
| 6 | 7 | Unchanged | |
| 7 | 8 | Unchanged | |
| 9 | Added | const HasTodos = RefSubject.map(App.TodoList, (list) => list.length > 0).pipe(Fx.skipRepeats); | |
| 8 | 10 | Unchanged | |
| 11 | Added | const TodoItem = component(function* (todo: RefSubject.RefSubject<Domain.Todo>, id: Domain.TodoId) { | |
| 12 | Added | const editing = yield* RefSubject.make(false); | |
| 13 | Added | const draft = yield* RefSubject.make(""); | |
| 14 | Added | | |
| 15 | Added | const text = RefSubject.map(todo, (value) => value.text); | |
| 16 | Added | const completed = RefSubject.map(todo, (value) => value.completed); | |
| 17 | Added | | |
| 18 | Added | const begin = text.pipe( | |
| 19 | Added | Effect.flatMap((value) => RefSubject.set(draft, value)), | |
| 20 | Added | Effect.flatMap(() => RefSubject.set(editing, true)), | |
| 21 | Added | ); | |
| 22 | Added | const cancel = RefSubject.set(editing, false); | |
| 23 | Added | const save = draft.pipe( | |
| 24 | Added | Effect.flatMap((value) => App.editTodo(id, value)), | |
| 25 | Added | Effect.flatMap(() => cancel), | |
| 26 | Added | ); | |
| 27 | Added | | |
| 28 | Added | return html`<li | |
| 29 | Added | class="${Fx.when(completed, { onTrue: "completed", onFalse: "" })} ${Fx.when(editing, { onTrue: "editing", onFalse: "" })}" | |
| 30 | Added | > | |
| 31 | Added | <div class="view"> | |
| 32 | Added | <input | |
| 33 | Added | class="toggle" | |
| 34 | Added | type="checkbox" | |
| 35 | Added | aria-label="Complete ${text}" | |
| 36 | Added | ?checked=${completed} | |
| 37 | Added | onchange=${App.toggleTodoCompleted(id)} | |
| 38 | Added | /> | |
| 39 | Added | <label ondblclick=${begin}>${text}</label> | |
| 40 | Added | <button | |
| 41 | Added | class="destroy" | |
| 42 | Added | type="button" | |
| 43 | Added | aria-label="Delete ${text}" | |
| 44 | Added | onclick=${App.deleteTodo(id)} | |
| 45 | Added | ></button> | |
| 46 | Added | </div> | |
| 47 | Added | <input | |
| 48 | Added | class="edit" | |
| 49 | Added | aria-label="Edit todo" | |
| 50 | Added | .value=${draft} | |
| 51 | Added | oninput=${EventHandler.make((event: InputEvent & { target: HTMLInputElement }) => | |
| 52 | Added | RefSubject.set(draft, event.target.value), | |
| 53 | Added | )} | |
| 54 | Added | onblur=${EventHandler.make( | |
| 55 | Added | () => Effect.flatMap(editing, (isEditing) => (isEditing ? save : Effect.void)), | |
| 56 | Added | { capture: true }, | |
| 57 | Added | )} | |
| 58 | Added | onkeydown=${EventHandler.make((event: KeyboardEvent) => { | |
| 59 | Added | if (event.key === "Escape") return cancel; | |
| 60 | Added | if (event.key === "Enter") return save; | |
| 61 | Added | | |
| 62 | Added | return undefined; | |
| 63 | Added | })} | |
| 64 | Added | /> | |
| 65 | Added | </li>`; | |
| 66 | Added | }); | |
| 67 | Added | | |
| 9 | 68 | Unchanged | const onInput = EventHandler.make((event: InputEvent & { target: HTMLInputElement }) => |
| 10 | 69 | Unchanged | RefSubject.set(App.TodoText, event.target.value), |
| 11 | 70 | Unchanged | ); |
| … 16 unchanged lines … | |||
| 28 | 87 | Unchanged | placeholder="What needs to be done?" |
| 29 | 88 | Unchanged | /> |
| 30 | 89 | Unchanged | </header> |
| 90 | Added | ${Fx.if(HasTodos, { | |
| 91 | Added | onTrue: html`<section class="main"> | |
| 92 | Added | <ul class="todo-list"> | |
| 93 | Added | ${many(App.Todos, (todo) => todo.id, TodoItem)} | |
| 94 | Added | </ul> | |
| 95 | Added | </section> | |
| 96 | Added | `, | |
| 97 | Added | onFalse: Fx.null, | |
| 98 | Added | })} | |
| 31 | 99 | Unchanged | </section> |
| 32 | 100 | Unchanged | <footer class="info"> |
| 33 | 101 | Unchanged | <p>Double-click to edit a todo</p> |
| … 2 unchanged lines … | |||
| Before | After | Change | Code |
|---|---|---|---|
| … 19 unchanged lines … | |||
| 20 | 20 | Unchanged | CreateTodo, |
| 21 | 21 | Unchanged | App.TodoList.make([]), |
| 22 | 22 | Unchanged | App.TodoText.make(""), |
| 23 | Added | App.FilterState.make("all"), | |
| 23 | 24 | Unchanged | ).pipe(Layer.provideMerge(router)); |
| 24 | 25 | Unchanged | |
| 25 | 26 | Unchanged | export const Services = makeServices(); |