Browse documentation

TUTORIAL / 04

Render the application shell

Connect native form events to application Effects.

The interactive example loads in the browser.

Type a title in the preview and submit it. The input clears; rows arrive in the next chapter. This step connects the form to the create action we already wrote.

Read input events in src/presentation.ts

import { RefSubject } from "@typed/fx";
import { EventHandler, html } from "@typed/template";
import * as App from "./application.js";

// 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 handler reads the browser’s latest value and writes it into TodoText. No Todo is created while typing.

Bind the input back to state

<input
  class="new-todo"
  aria-label="New todo"
  autocomplete="off"
  .value=${App.TodoText}
  oninput=${onInput}
  placeholder="What needs to be done?"
/>

.value updates the live input property. When createTodo clears the draft, the rendered input clears too. The HTML value attribute alone would not describe that ongoing relationship.

Submit through the form

<form
  class="add-todo"
  onsubmit=${EventHandler.make(() => App.createTodo, { preventDefault: true })}
>

The form runs App.createTodo and prevents a page navigation. Clicking Add todo and pressing Enter take the same path. The button is a native submit button:

  <button type="submit" class="add-todo-button">Add todo</button>
</form>

The surrounding TodoApp is simply html followed by a template literal. It binds existing state and actions, so no generator or component() wrapper is needed. Open the complete presentation file below to see the surrounding section and header.

Try it: submit spaces, then a title with spaces around it. Blank input stays available for correction; accepted input clears. If Enter reloads the page, check preventDefault. If the visible text stays after a successful submission, 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
import { RefSubject } from "@typed/fx";
import { EventHandler, html } from "@typed/template";
import * as App from "./application.js";

// 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>`;
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 TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {}
export class CreateTodo extends Context.Service<
  CreateTodo,
  (text: string) => Effect.Effect<Domain.Todo>
>()("CreateTodo") {}

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, "");
});
src/infrastructure.ts
import { DateTime, Effect, Layer } from "effect";
import * as App from "./application.js";
import * as Domain from "./domain.js";

export const Services = Layer.mergeAll(
  App.TodoList.make([]),
  App.TodoText.make(""),
  Layer.succeed(App.CreateTodo, (text: string) =>
    Effect.sync((): Domain.Todo => ({
      id: Domain.TodoId.make(crypto.randomUUID()),
      text,
      completed: false,
      timestamp: DateTime.makeUnsafe(new Date()),
    })),
  ),
);
src/preview.ts
import { Fx } from "@typed/fx";
import { TodoApp } from "./presentation.js";
import { Services } from "./infrastructure.js";

export const Preview = TodoApp.pipe(Fx.provide(Services));