A submission should reject blank text, create one item, and clear the input only after accepting it. Add RefArray to the @typed/fx imports in src/application.ts, then write the action.
Write the create action
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,
),
);
Read it from top to bottom. Effect.flatMap(TodoText, …) gets the current draft. Whitespace returns before calling the factory. The factory receives the original nonblank text, and its complete result enters the list before the draft is cleared.
That order protects the user’s input: if creation fails, execution never reaches the clear. Generating the ID before insertion also gives the future row a stable key from its first render.
Run the same action for every submission
createTodo is an Effect value. Defining it does not read the draft. Each run reads current state, so an event handler can run this same action repeatedly without capturing yesterday’s text.
The action does not know about KeyboardEvent or HTMLInputElement. In the next chapter, a native input will translate browser input into these state reads and writes.
Trace three inputs: spaces leave the list and draft alone; Learn Typed adds Learn Typed and clears the draft; an unavailable factory leaves the original text for a retry. Add the test file below and run npm exec vitest -- run src/application.test.ts. It supplies a predictable factory and tests all three cases against this action.
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/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/application.test.ts
import { DateTime, Effect, Exit, Layer } from "effect";
import { RefSubject } from "@typed/fx";
import { describe, expect, it } from "vitest";
import * as App from "./application.js";
import * as Domain from "./domain.js";
const timestamp = DateTime.makeUnsafe("2026-01-01T00:00:00Z");
const model = Layer.mergeAll(
App.TodoList.make([]),
App.TodoText.make(""),
);
describe("createTodo", () => {
it("ignores blank drafts and commits the entered text before clearing the input", () => {
// A blank draft must not reach the factory, even if it leaves the list unchanged.
let calls = 0;
const create = Layer.succeed(App.CreateTodo, (text) =>
Effect.sync((): Domain.Todo => ({
id: Domain.TodoId.make(`todo-${++calls}`),
text,
completed: false,
timestamp,
})),
);
return Effect.gen(function* () {
yield* RefSubject.set(App.TodoText, " ");
yield* App.createTodo;
expect(calls).toBe(0);
expect(yield* App.TodoList).toEqual([]);
expect(yield* App.TodoText).toBe(" ");
yield* RefSubject.set(App.TodoText, " Learn Typed ");
yield* App.createTodo;
expect(calls).toBe(1);
expect(yield* App.TodoList).toEqual([
{ id: Domain.TodoId.make("todo-1"), text: " Learn Typed ", completed: false, timestamp },
]);
expect(yield* App.TodoText).toBe("");
}).pipe(Effect.provide([model, create]), Effect.scoped, Effect.runPromise);
});
it("preserves the draft when the factory cannot create an item", () =>
Effect.gen(function* () {
yield* RefSubject.set(App.TodoText, "Keep this draft");
const exit = yield* Effect.exit(App.createTodo);
expect(Exit.isFailure(exit)).toBe(true);
expect(yield* App.TodoList).toEqual([]);
expect(yield* App.TodoText).toBe("Keep this draft");
}).pipe(
Effect.provide([
model,
Layer.succeed(App.CreateTodo, () => Effect.die("ID factory unavailable")),
]),
Effect.scoped,
Effect.runPromise,
));
});
Changes from Own application state1 file
| Before | After | Change | Code |
|---|---|---|---|
| 1 | 1 | Unchanged | import * as Effect from "effect/Effect"; |
| 2 | Removed | | |
| 2 | Added | import * as Option from "effect/Option"; | |
| 3 | 3 | Unchanged | import * as Context from "effect/Context"; |
| 4 | Removed | | |
| 4 | Added | import { RefArray, RefSubject } from "@typed/fx"; | |
| 5 | 5 | Unchanged | import * as Domain from "./domain.js"; |
| 6 | 6 | Unchanged | |
| 7 | 7 | Unchanged | export class CreateTodo extends Context.Service< |
| … 7 unchanged lines … | |||
| 15 | 15 | Unchanged | export class TodoList extends RefSubject.Service<TodoList, Domain.TodoList>()("TodoList") {} |
| 16 | 16 | Unchanged | |
| 17 | 17 | Unchanged | export class TodoText extends RefSubject.Service<TodoText, string>()("TodoText") {} |
| 18 | Added | | |
| 19 | Added | export const createTodo: Effect.Effect< | |
| 20 | Added | Option.Option<Domain.Todo>, | |
| 21 | Added | never, | |
| 22 | Added | CreateTodo | TodoList | TodoText | |
| 23 | Added | > = Effect.flatMap(TodoText, (text: string) => | |
| 24 | Added | text.trim() === "" | |
| 25 | Added | ? Effect.succeed(Option.none<Domain.Todo>()) | |
| 26 | Added | : CreateTodo.call(text).pipe( | |
| 27 | Added | Effect.tap((todo) => RefArray.prepend(TodoList, todo)), | |
| 28 | Added | Effect.tap(() => RefSubject.set(TodoText, "")), | |
| 29 | Added | Effect.asSome, | |
| 30 | Added | ), | |
| 31 | Added | ); | |