The interactive example loads in the browser.
The interactive example loads in the browser.
Type a title and press Enter. The input clears after creation; this partial preview does not reveal the list until the next chapter. Its header and event handlers are extracted from the actual TodoMVC presentation.
Run this shell locally
Keep the Quick Start index.html and replace the files below. The infrastructure now supplies a router; install it alongside the standalone styles, then start Vite:
npm install --save-exact @typed/router@1.0.0-beta.11
npm install todomvc-app-css todomvc-common
npm run dev
Read input events in src/presentation.ts
The handler records browser edits in the application draft.
const onInput = EventHandler.make((event: InputEvent & { target: HTMLInputElement }) =>
RefSubject.set(App.TodoText, event.target.value),
Changing the draft does not create a Todo.
Create on Enter
The keyboard handler runs the create action when the user presses Enter.
const onNewTodoKeydown = EventHandler.make((event: KeyboardEvent) =>
event.key === "Enter" ? App.createTodo : undefined,
The canonical input accepts Enter; blank drafts remain unchanged.
Bind the input back to state
The live value follows the same draft and clears when the action succeeds.
.value=${App.TodoText}
oninput=${onInput}
onkeydown=${onNewTodoKeydown}
placeholder="What needs to be done?"
The outer view is an html template. It does not allocate component-local state.
Try it: press Enter with only spaces, then with a title. Blank input leaves the draft unchanged; accepted input clears it. Rows arrive in the next chapter. If the visible text stays after successful creation, check .value.
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/presentation.ts
// oxlint-disable require-yield
import { RefSubject } from "@typed/fx";
import { EventHandler, html } from "@typed/template";
import * as App from "./application.js";
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>
</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/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 TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {}
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,
),
);
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(""),
).pipe(Layer.provideMerge(router));
export const Services = makeServices();
Optional website embedding: src/preview.ts
The documentation uses this entrypoint to isolate its preview. Your standalone app starts from src/main.ts; it does not need preview.ts. The filter chapter explains the difference.
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/" }))),
);
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,
);
src/styles.css
@import "todomvc-common/base.css";
@import "todomvc-app-css/index.css";