Async work
The editor from chapter 1 holds text and nothing else. Saving it needs a service to call, a way to run the call, and somewhere to put the outcome.
You write the save by hand first, with a Command. Then you hit the two
problems every save button has, and a Task in the tasks slot folds the
fixes into one field.
1. Declare the service
The save call lives in an Effect service, so a test can hand the feature a different implementation.
// notes-api.ts
import { Context, Effect, Layer } from "effect";
export class NotesApi extends Context.Service<
NotesApi,
{
readonly save: (note: {
readonly id: string;
readonly text: string;
}) => Effect.Effect<string, Error>;
}
>()("NotesApi") {}
export const notesApiLayer = Layer.succeed(NotesApi)({
save: (note) => Effect.succeed(`${note.id}@${Date.now()}`),
});save answers with the revision id of the stored note. The layer above is a
stub. Swap in a real HTTP call and nothing else on this page changes.
2. Hand the layer to the runtime
createRuntime takes the root layer. Every command mounted under it can ask
for NotesApi.
// runtime.ts
import { createRuntime } from "@wych/react";
export const { component } = createRuntime(notesApiLayer);A feature whose commands need a service the root does not have is a compile
error at component.
3. Save by hand
A handler cannot call the service. It returns a Command that describes the
call, and the runtime runs it. Command.effect takes a function of
dispatch: the effect does its work, then dispatches an action with the
result.
// note-editor.tsx
import { Cause, Schema } from "effect";
import { Action, Command, define } from "@wych/react";
const byHandActions = Action({
TextChanged: { text: Schema.String },
Reverted: {},
SaveClicked: {},
Saved: { revision: Schema.String },
SaveFailed: { message: Schema.String },
});
const ByHand = define({
props: Schema.Struct({ noteId: Schema.String, initialText: Schema.String }),
state: Schema.Struct({
text: Schema.String,
dirty: Schema.Boolean,
saving: Schema.Boolean,
error: Schema.String,
}),
actions: byHandActions,
});
const byHandInitialState = ByHand.initialState((props) => ({
text: props.initialText,
dirty: false,
saving: false,
error: "",
}));
const byHandReducer = ByHand.reducer({
TextChanged: ({ text }, { draft, props }) => {
draft.text = text;
draft.dirty = text !== props.initialText;
return draft;
},
Reverted: (_payload, { draft, props }) => {
draft.text = props.initialText;
draft.dirty = false;
return draft;
},
SaveClicked: (_payload, { draft, state, props }) => {
draft.saving = true;
draft.error = "";
return [
draft,
Command.effect((dispatch) =>
Effect.gen(function* () {
const api = yield* NotesApi;
const revision = yield* api.save({ id: props.noteId, text: state.text });
yield* dispatch(byHandActions.Saved, { revision });
}).pipe(
Effect.catchCause((cause) => {
const error = Cause.squash(cause);
const message = error instanceof Error ? error.message : String(error);
return dispatch(byHandActions.SaveFailed, { message });
}),
),
),
];
},
Saved: (_payload, { draft }) => {
draft.saving = false;
draft.dirty = false;
return draft;
},
SaveFailed: ({ message }, { draft }) => {
draft.saving = false;
draft.error = message;
return draft;
},
});
const byHandRender = ByHand.render(({ state, dispatch }) => (
<form>
<textarea
value={state.text}
onChange={(event) => dispatch(byHandActions.TextChanged, { text: event.target.value })}
/>
<button
type="button"
disabled={state.saving}
onClick={() => dispatch(byHandActions.SaveClicked)}
>
{state.saving ? "Saving..." : "Save"}
</button>
{state.error !== "" && <span role="alert">{state.error}</span>}
</form>
));
const byHand = ByHand.create({
initialState: byHandInitialState,
reducer: byHandReducer,
render: byHandRender,
});Three things to notice. saving: true is written on the same fold that
returns the command, so the button is disabled before the click handler
returns. The effect reads like a function body: get the service, call it,
dispatch the result. Its error channel must be never, which is what
catchCause is for: every failure becomes a SaveFailed action the reducer
can render. And dispatch inside the effect is typed to this feature's
actions, so a message from another feature or a wrong payload is a compile
error.
feature.run folds a list of actions, runs every command against a layer
you choose, and folds what the commands dispatch back. No React in the path.
A seed is a built message, and make needs no argument when the message has
no fields.
const stubSave = Layer.succeed(NotesApi)({
save: (note) => Effect.succeed(`${note.id}@1`),
});
const oneSave = await Effect.runPromise(
byHand.run([byHandActions.SaveClicked.make()], {
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
layer: stubSave,
}),
);
console.log(oneSave.emitted);
// => [{ _tag: "Saved", revision: "n1@1" }]
console.log(oneSave.state.saving);
// => falseemitted holds what the commands dispatched. The SaveClicked you seeded
is folded but never appears there.
4. Two clicks, two requests
Make the stub slow enough that the first save is still in flight when the second click arrives.
const slowSave = Layer.succeed(NotesApi)({
save: (note) =>
Effect.gen(function* () {
yield* Effect.sleep("50 millis");
return `${note.id}@2`;
}),
});
const twoByHand = await Effect.runPromise(
byHand.run([byHandActions.SaveClicked.make(), byHandActions.SaveClicked.make()], {
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
layer: slowSave,
}),
);
console.log(twoByHand.emitted.map((action) => action._tag));
// => ["Saved", "Saved"]Two requests went out. The disabled button would have stopped a real user, but nothing in the reducer did, and a keyboard shortcut or a retry loop does not read the button. The rule belongs in the handler: a save that is already pending is ignored.
const guarded = ByHand.create({
initialState: byHandInitialState,
reducer: {
...byHandReducer,
SaveClicked: (payload, snapshot) =>
snapshot.state.saving ? snapshot.state : byHandReducer.SaveClicked(payload, snapshot),
},
render: byHandRender,
});
const twoGuarded = await Effect.runPromise(
guarded.run([byHandActions.SaveClicked.make(), byHandActions.SaveClicked.make()], {
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
layer: slowSave,
}),
);
console.log(twoGuarded.emitted.map((action) => action._tag));
// => ["Saved"]That is the first problem. The second is the state itself: saving and
error are two fields that can disagree. Nothing stops saving: true next
to a stale error, and there is no place for the revision the save returned.
A save has four outcomes, and two booleans cannot spell four cases.
5. The same thing as a Task
Task is the by-hand version with the parts folded in. It declares the two
result actions, the command, the failure mapping and the state field, from a
name and a schema of what the work returns.
import { Task } from "@wych/react";
const saveNote = Task("Save", {
success: Schema.String,
mode: "first",
run: (note: { readonly id: string; readonly text: string }) =>
Effect.gen(function* () {
const api = yield* NotesApi;
return yield* api.save(note);
}),
});Each piece replaces something from step 3 or 4:
saveNotedeclaresSaveResolved { value }andSaveRejected { error }, in place ofSavedandSaveFailed.saveNote.run(note)is theCommand.effectwithcatchCauseinside.- The
catchCausebody isTask.errorMessage, the message off the cause. It is the default; afailureschema of your own takes anonErrorbeside it. mode: "first"is the guard from step 4: while a save is in flight, the runtime drops a new start.saveNote.cancelinterrupts the save in flight. By hand that needs a named group; see groups and cancellation.
The state field, in place of saving and error, comes from the tasks
slot on define. The key names the field.
const actions = Action({
TextChanged: { text: Schema.String },
Reverted: {},
SaveClicked: {},
SaveCancelled: {},
});
const Editor = define({
props: Schema.Struct({ noteId: Schema.String, initialText: Schema.String }),
state: Schema.Struct({
text: Schema.String,
dirty: Schema.Boolean,
}),
tasks: { save: saveNote },
actions,
});
const initialState = Editor.initialState((props) => ({
text: props.initialText,
dirty: false,
}));tasks: { save: saveNote } adds a save field to the state and brings the
two actions with it. The field holds one of four cases: Idle, Pending,
Resolved { value }, Rejected { error }. It starts Idle, so
initialState leaves it out.
const reducer = Editor.reducer({
TextChanged: ({ text }, { draft, props, tasks }) => {
draft.text = text;
draft.dirty = text !== props.initialText;
return tasks.save.cancel();
},
Reverted: (_payload, { draft, props, tasks }) => {
draft.text = props.initialText;
draft.dirty = false;
return tasks.save.cancel();
},
SaveClicked: (_payload, { state, props, tasks }) =>
tasks.save.start({ id: props.noteId, text: state.text }),
SaveCancelled: (_payload, { tasks }) => tasks.save.cancel(),
SaveResolved: (_payload, { draft }) => {
draft.dirty = false;
return draft;
},
});The snapshot carries one handle per key of the slot, under tasks.
tasks.save.start(note) writes Pending into save and returns the draft
beside the command, the same two lines SaveClicked wrote by hand. Under
mode: "first", the runtime drops the command while a save is in flight, so
the second click sends no request. tasks.save.cancel() writes Idle and
interrupts the save. TextChanged and Reverted return it too: an edit
makes the save in flight stale, so it stops before its result can mark the
new text as saved.
When the save settles, the runtime writes Resolved { value } or
Rejected { error } into save before any handler runs. So the reducer
owes no handler for SaveResolved and SaveRejected. A resolved save also
clears dirty, and that is what the SaveResolved handler above is for:
the draft already holds the settled field, and the handler adds its own
write.
A settle handler receives the payload like any other handler:
SaveResolved: ({ value }, { draft }) => …. Interruption is a normal ending
for a task: a cancelled save dispatches neither SaveResolved nor
SaveRejected. The full contract of the slot and its handles is in
Tasks.
6. Render the four cases
The field holds one of four cases, and the view has to handle each one.
Task.match takes the field and one function per case, and is exhaustive: a
missing case does not compile.
const render = Editor.render(({ state, dispatch }) => (
<form>
<textarea
value={state.text}
onChange={(event) => dispatch(actions.TextChanged, { text: event.target.value })}
/>
<button type="button" disabled={!state.dirty} onClick={() => dispatch(actions.Reverted)}>
Revert
</button>
<button type="button" onClick={() => dispatch(actions.SaveClicked)}>
Save
</button>
<button type="button" onClick={() => dispatch(actions.SaveCancelled)}>
Cancel
</button>
{Task.match(state.save, {
Idle: () => null,
Pending: () => <span>Saving...</span>,
Resolved: ({ value }) => <span>Saved as {value}</span>,
Rejected: ({ error }) => <span role="alert">{error}</span>,
})}
</form>
));Each function receives its whole case, so Resolved reads value and
Rejected reads error. The four may return different types.
7. Mount it
main.tsx is unchanged from chapter 1.
const editor = Editor.create({ initialState, reducer, render });
export const NoteEditor = component(editor, { name: "NoteEditor" });Press Save and the label reads Saving..., then Saved as n1@.... The
revision comes from the stub layer.
8. The same tests, through Task
Two clicks still produce one save, and the outcome now lands in the field.
const twoSaves = await Effect.runPromise(
editor.run([actions.SaveClicked.make(), actions.SaveClicked.make()], {
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
layer: slowSave,
}),
);
console.log(twoSaves.emitted.map((action) => action._tag));
// => ["SaveResolved"]
console.log(twoSaves.state.save);
// => { _tag: "Resolved", value: "n1@2" }mode is declared once on the operation. "first" sends the first request
and ignores the second, which is what a save wants. The default "latest"
interrupts the running request when a new one starts, so the second click
wins; a search wants that. "every", where both requests land in order, is
in debounce and take latest; the
option is in tasks.
A settle is an action, so feature.reduce folds one on its own.
saveNote.Resolved builds it, and the state includes the field.
import { Next } from "@wych/react";
const settled = editor.reduce(saveNote.Resolved.make({ value: "n1@3" }), {
state: { text: "Buy milk", dirty: true, save: Task.pending },
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
});
console.log(Next.state(settled));
// => { text: "Buy milk", dirty: false, save: { _tag: "Resolved", value: "n1@3" } }The field was written by the runtime, dirty by the handler, on one fold.
A failing layer lands in the same field, with the message Task.errorMessage
took off the cause.
const failingSave = Layer.succeed(NotesApi)({
save: () => Effect.fail(new Error("offline")),
});
const failed = await Effect.runPromise(
editor.run([actions.SaveClicked.make()], {
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
layer: failingSave,
}),
);
console.log(failed.state.save);
// => { _tag: "Rejected", error: "offline" }9. Cancel a save in flight
const cancelled = await Effect.runPromise(
editor.run([actions.SaveClicked.make(), actions.SaveCancelled.make()], {
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
layer: slowSave,
}),
);
console.log(cancelled.emitted);
// => []
console.log(cancelled.state.save);
// => { _tag: "Idle" }The files
src/
main.tsx # mounts both editors
note-editor-by-hand.tsx # step 3 and 4: Command.effect, the guard
note-editor.tsx # step 5 to 7: the Task version
note-editor.test.ts # steps 4, 8 and 9 as vitest tests
notes-api.ts # the NotesApi service and its layer
runtime.ts # createRuntime(notesApiLayer)Split across the files, the imports between them are:
import { notesApiLayer } from "./notes-api"; // runtime.ts
import { NotesApi } from "./notes-api"; // note-editor.tsx, note-editor-by-hand.tsx
import { component } from "./runtime"; // note-editor.tsx, note-editor-by-hand.tsx
import { NoteEditor } from "./note-editor"; // main.tsx
import { actions, editor, saveNote } from "./note-editor"; // note-editor.test.ts
import { actions as byHandActions, byHand } from "./note-editor-by-hand"; // note-editor.test.tsNext
One editor saves one note. A list that mounts many editors, and hears about every save, is chapter 3.
For every option on Task, including mode: "every" and a failure schema
with its onError, see Tasks. For the command
constructors underneath it, see Commands.
Every command in this chapter completes: a save returns or fails, and run
resolves once nothing is in flight. A source that never completes, such as a
websocket, a presence feed or a Stream.tick, is a subscription, not a
command. The feature declares it under a key, and the runtime starts and
stops it as the key changes. That recipe is
subscribe to a stream.