Tasks

A task is async work as two actions and a command. Task(name, config) declares ${Name}Resolved and ${Name}Rejected plus the command that produces them. The result lands in a TaskValue field, which has four cases. The tasks slot on define binds an operation to that field under its key.

Every snippet on this page builds on one feature: a mailbox that loads the subjects of a folder through a MailApi, holds them in state.subjects, and can be cancelled.

import { Cause, Context, Effect, Layer, Option, Schema } from "effect";
import { Action, Command, define, Next, Task } from "@wych/react";
import type { TagsOf, TaskMode, TaskOnError, TaskOperation, TaskValue } from "@wych/react";

class MailApi extends Context.Service<
  MailApi,
  { readonly list: (folder: string) => Effect.Effect<ReadonlyArray<string>, Error> }
>()("MailApi") {}

const MailApiLayer = Layer.succeed(MailApi)({ list: () => Effect.succeed(["Hello"]) });

const Subjects = Schema.Array(Schema.String);

const loadMail = Task("LoadMail", {
  success: Subjects,
  run: (folder: string) =>
    Effect.gen(function* () {
      const api = yield* MailApi;
      return yield* api.list(folder);
    }),
});

const actions = Action({
  Opened: { folder: Schema.String },
  Cancelled: {},
});

const Mailbox = define({
  props: Schema.Struct({}),
  state: Schema.Struct({ folder: Schema.String, count: Schema.Number }),
  tasks: { subjects: loadMail },
  actions,
});

export const mailbox = Mailbox.create({
  initialState: Mailbox.initialState(() => ({ folder: "", count: 0 })),
  reducer: Mailbox.reducer({
    Opened: ({ folder }, { draft, tasks }) => {
      draft.folder = folder;
      return tasks.subjects.start(folder);
    },
    Cancelled: (_payload, { tasks }) => tasks.subjects.cancel(),
    LoadMailResolved: ({ value }, { draft }) => {
      draft.count = value.length;
      return draft;
    },
  }),
  render: Mailbox.render(({ state }) =>
    Task.match(state.subjects, {
      Idle: () => null,
      Pending: () => <p>Loading</p>,
      Resolved: ({ value }) => (
        <ul>
          {value.map((subject) => (
            <li key={subject}>{subject}</li>
          ))}
        </ul>
      ),
      Rejected: ({ error }) => <p>{error}</p>,
    }),
  ),
});

Task

Task<Name extends Capitalize<string>, Success extends Schema.Top, Input, R>(
  name: Name,
  config: {
    readonly success: Success;
    readonly failure?: undefined;
    readonly onError?: TaskOnError<string>;
    readonly mode?: TaskMode;
    readonly run?: (input: Input) => Effect.Effect<Success["Type"], unknown, R>;
  },
): TaskOperation<Name, Success, Schema.String, Input, R>

Task<Name, Success, Failure extends Schema.Top, Input, R>(
  name: Name,
  config: {
    readonly success: Success;
    readonly failure: Failure;
    readonly onError: TaskOnError<Failure["Type"]>;
    readonly mode?: TaskMode;
    readonly run?: (input: Input) => Effect.Effect<Success["Type"], unknown, R>;
  },
): TaskOperation<Name, Success, Failure, Input, R>

Without failure, the field's error is a string and onError defaults to Task.errorMessage; loadMail above declares neither. With a failure schema, onError is required and produces its type.

const NotFound = Schema.Struct({ status: Schema.Number, message: Schema.String });

const typedLoad = Task("TypedLoad", {
  success: Subjects,
  failure: NotFound,
  onError: (cause): typeof NotFound.Type => ({
    status: Cause.hasDies(cause) ? 500 : 404,
    message: String(Cause.squash(cause)),
  }),
  run: (folder: string) =>
    Effect.gen(function* () {
      const api = yield* MailApi;
      return yield* api.list(folder);
    }),
});

A failure schema without onError is a compile error, Schema.String included: the default mapping pairs with the default schema only.

// @ts-expect-error onError is required with a failure schema
const missingOnError = Task("MissingOnError", { success: Subjects, failure: Schema.String });

name must be capitalized, because it prefixes two action tags.

// @ts-expect-error "loadMail" is not Capitalize<string>
const lowercase = Task("loadMail", { success: Subjects });

The tasks slot

define({
  props: Schema.Struct,
  state: Schema.Struct,
  tasks?: { readonly [key: string]: TaskOperation }, // internal operations only
  actions: MemberSource<"internal">,
  outputs?: MemberSource<"outbound">,
})
// State: the state schema's fields plus one TaskValue<Success, Failure> per key
// Action: the declared actions plus `${Name}Resolved` and `${Name}Rejected` per task

tasks takes a record of internal operations. Each key adds a TaskValue<Success, Failure> field to State, typed by the operation's schema, and the operation's two actions to the action union. Mailbox declares subjects this way, so state.subjects exists without a line in the state schema.

The field starts Idle: initialState is typed without the task keys, and the runtime fills each key with Task.idle under what initialState returns. A hand-built state for reduce includes the key.

const fresh = await Effect.runPromise(
  mailbox.run([], { props: {}, hooks: {}, layer: MailApiLayer }),
);

console.log(fresh.state);
// => { folder: "", count: 0, subjects: { _tag: "Idle" } }

const idleMailbox = { state: { folder: "", count: 0, subjects: Task.idle }, props: {}, hooks: {} };
const pendingMailbox = {
  state: { folder: "inbox", count: 0, subjects: Task.pending },
  props: {},
  hooks: {},
};

subscriptions stays on create: it is a function of the snapshot. The task binding is a static declaration, like the state schema, so it lives on define.

snapshot.tasks

interface ReducerSnapshot<Props, State, H, Tasks> extends Snapshot<Props, State, H> {
  readonly draft: Draft<State>;
  readonly tasks: TaskHandles<Tasks, State>; // one handle per key of the slot
}

// the handle under snapshot.tasks[key]
start(input: Input): readonly [Draft<State>, Command<TaskAction<Name, Success, Failure>, R>]
// for an operation declared without `run`
start<V extends Success, E = never, R2 = never>(
  effect: Effect.Effect<V, E, R2>,
): readonly [Draft<State>, Command<TaskAction<Name, Success, Failure>, R2>]
cancel(): readonly [Draft<State>, Command<TaskAction<Name, Success, Failure>>]

A reducer handler receives one handle per key, lifecycle handlers included. Both methods write the field into snapshot.draft and return the draft beside the operation's command, so the call is the handler's return. A feature without a slot hands {}. render and subscriptions have no handles: neither changes state.

start(input)

start(input) writes Pending into the draft and returns [draft, op.run(input)]. The command carries the operation's R, so the feature's service requirements read it off the handler's return. A handler may write other draft fields before or after the call; mailbox's Opened handler writes folder first.

const opened = mailbox.reduce(actions.Opened.make({ folder: "inbox" }), idleMailbox);

console.log(Next.state(opened));
// => { folder: "inbox", count: 0, subjects: { _tag: "Pending" } }
console.log(Next.command(opened) !== undefined);
// => true

An operation declared without run has a start that takes the effect.

const Upload = Task("Upload", { success: Schema.String });

const Uploader = define({
  props: Schema.Struct({}),
  state: Schema.Struct({ name: Schema.String }),
  tasks: { receipt: Upload },
  actions: actions.Opened,
});

const uploader = Uploader.create({
  initialState: () => ({ name: "" }),
  reducer: {
    Opened: ({ folder }, { tasks }) => tasks.receipt.start(Effect.succeed(`receipt:${folder}`)),
  },
  render: () => null,
});

const uploaded = await Effect.runPromise(
  uploader.run([actions.Opened.make({ folder: "inbox" })], {
    props: {},
    hooks: {},
    layer: Layer.empty,
  }),
);

console.log(uploaded.state);
// => { name: "", receipt: { _tag: "Resolved", value: "receipt:inbox" } }

cancel()

cancel() writes Idle into the draft and returns [draft, op.cancel], which interrupts the work in flight.

const cancelled = mailbox.reduce(actions.Cancelled.make(), pendingMailbox);

console.log(Next.state(cancelled));
// => { folder: "inbox", count: 0, subjects: { _tag: "Idle" } }
console.log(Next.command(cancelled)?._tag);
// => "Cancel"

Through run, a start followed by a cancel emits nothing and leaves the field Idle.

const SlowApiLayer = Layer.succeed(MailApi)({
  list: (folder) => Effect.as(Effect.sleep("50 millis"), [folder] as ReadonlyArray<string>),
});

const cancelledRun = await Effect.runPromise(
  mailbox.run([actions.Opened.make({ folder: "inbox" }), actions.Cancelled.make()], {
    props: {},
    hooks: {},
    layer: SlowApiLayer,
  }),
);

console.log(cancelledRun.state.subjects);
// => { _tag: "Idle" }
console.log(cancelledRun.emitted);
// => []

Settle handlers

The ${Name}Resolved and ${Name}Rejected keys are optional in the reducer of a slot task. The fold writes Task.resolved(value) or Task.rejected(error) into the field first; with no handler, that write is the result. mailbox has no LoadMailRejected handler.

const rejectedByFold = mailbox.reduce(loadMail.Rejected.make({ error: "offline" }), pendingMailbox);

console.log(Next.state(rejectedByFold));
// => { folder: "inbox", count: 0, subjects: { _tag: "Rejected", error: "offline" } }

A settle handler receives the action's payload, { value } or { error }, like every other handler, and runs over the state with the field already written: both snapshot.state and snapshot.draft hold the settled case. mailbox's LoadMailResolved handler counts the subjects beside the write.

const resolvedByFold = mailbox.reduce(
  loadMail.Resolved.make({ value: ["Hello", "Re: Hello"] }),
  pendingMailbox,
);

console.log(Next.state(resolvedByFold));
// => { folder: "inbox", count: 2, subjects: { _tag: "Resolved", value: ["Hello", "Re: Hello"] } }

Returning snapshot.state from a settle handler returns the field write alone.

let seenByHandler = "";

const observing = Mailbox.create({
  initialState: Mailbox.initialState(() => ({ folder: "", count: 0 })),
  reducer: Mailbox.reducer({
    Opened: ({ folder }, { tasks }) => tasks.subjects.start(folder),
    Cancelled: (_payload, { tasks }) => tasks.subjects.cancel(),
    LoadMailResolved: (_payload, { state, draft }) => {
      seenByHandler = `${state.subjects._tag}/${draft.subjects._tag}`;
      return state;
    },
  }),
  render: Mailbox.render(() => null),
});

const observed = observing.reduce(loadMail.Resolved.make({ value: ["Hello"] }), pendingMailbox);

console.log(Next.state(observed));
// => { folder: "inbox", count: 0, subjects: { _tag: "Resolved", value: ["Hello"] } }
console.log(seenByHandler);
// => "Resolved/Resolved"

Writing into the draft and returning another state is the fold's TypeError, on the finishing rules.

const mixedSettle = Mailbox.create({
  initialState: Mailbox.initialState(() => ({ folder: "", count: 0 })),
  reducer: Mailbox.reducer({
    Opened: ({ folder }, { tasks }) => tasks.subjects.start(folder),
    Cancelled: (_payload, { tasks }) => tasks.subjects.cancel(),
    LoadMailResolved: ({ value }, { state, draft }) => {
      draft.count = value.length;
      return state;
    },
  }),
  render: Mailbox.render(() => null),
});

mixedSettle.reduce(loadMail.Resolved.make({ value: ["Hello"] }), pendingMailbox);
// throws TypeError: handler wrote into snapshot.draft and returned a different state

Resolved and Rejected

readonly Resolved: Message<`${Name}Resolved`, { readonly value: Success }>;
readonly Rejected: Message<`${Name}Rejected`, { readonly error: Failure }>;

The operation's two message schemas by name, the same pair as actions. make builds the message a test seeds or folds.

console.log(loadMail.Resolved.make({ value: ["Hello"] }));
// => { _tag: "LoadMailResolved", value: ["Hello"] }
console.log(loadMail.Rejected.make({ error: "offline" }));
// => { _tag: "LoadMailRejected", error: "offline" }
console.log(loadMail.Resolved === loadMail.actions[0]);
// => true

TaskMode

type TaskMode = "latest" | "every" | "first";

Concurrency is a property of the operation, declared once. "latest" is the default and uses Command.restart: a second start interrupts the first. "every" uses Command.keyed: both runs go to completion and the last to settle wins. "first" keeps the running one: its command is a Keyed node flagged first, and the runtime drops that node while a run of the operation is in flight on the mount. The rule holds on every path: the slot's start, op.run, Task.start and Task.output.

SlowApiLayer resolves each folder to itself after a delay, so the folder in the field names the run that settled.

const twoFolders = [
  actions.Opened.make({ folder: "inbox" }),
  actions.Opened.make({ folder: "sent" }),
];

const latest = await Effect.runPromise(
  mailbox.run(twoFolders, { props: {}, hooks: {}, layer: SlowApiLayer }),
);

console.log(latest.state.subjects);
// => { _tag: "Resolved", value: ["sent"] }
console.log(latest.emitted.length);
// => 1
const everyLoad = Task("EveryLoad", {
  success: Subjects,
  mode: "every" satisfies TaskMode,
  run: (folder: string) =>
    Effect.gen(function* () {
      const api = yield* MailApi;
      return yield* api.list(folder);
    }),
});

const everyMailbox = define({
  props: Schema.Struct({}),
  state: Schema.Struct({}),
  tasks: { subjects: everyLoad },
  actions,
}).create({
  initialState: () => ({}),
  reducer: {
    Opened: ({ folder }, { tasks }) => tasks.subjects.start(folder),
    Cancelled: (_payload, { tasks }) => tasks.subjects.cancel(),
  },
  render: () => null,
});

const every = await Effect.runPromise(
  everyMailbox.run(twoFolders, { props: {}, hooks: {}, layer: SlowApiLayer }),
);

console.log(every.emitted.length);
// => 2
const firstLoad = Task("FirstLoad", {
  success: Subjects,
  mode: "first",
  run: (folder: string) =>
    Effect.gen(function* () {
      const api = yield* MailApi;
      return yield* api.list(folder);
    }),
});

const firstMailbox = define({
  props: Schema.Struct({}),
  state: Schema.Struct({}),
  tasks: { subjects: firstLoad },
  actions,
}).create({
  initialState: () => ({}),
  reducer: {
    Opened: ({ folder }, { tasks }) => tasks.subjects.start(folder),
    Cancelled: (_payload, { tasks }) => tasks.subjects.cancel(),
  },
  render: () => null,
});

const alreadyPending = { state: { subjects: Task.pending }, props: {}, hooks: {} };
const dropped = firstMailbox.reduce(actions.Opened.make({ folder: "sent" }), alreadyPending);

console.log(Next.state(dropped) === alreadyPending.state);
// => true
console.log(Next.command(dropped)?._tag);
// => "Keyed"

const first = await Effect.runPromise(
  firstMailbox.run(twoFolders, { props: {}, hooks: {}, layer: SlowApiLayer }),
);

console.log(first.state.subjects);
// => { _tag: "Resolved", value: ["inbox"] }
console.log(first.emitted.length);
// => 1

start leaves a field that is already Pending untouched, so the fold returns snapshot.state itself, and the command still issues. The runtime decides whether the work runs from the fibers it holds for the operation, so a run that ended without settling (a raw cancel that left the field Pending, an unmount) does not block the next start. A start after the settle runs again, and a cancel() writes Idle.

Clash rules

Each rule throws a TypeError at define, and is a compile error where the types can express it.

A task key equal to a state field:

define({
  props: Schema.Struct({}),
  state: Schema.Struct({ folder: Schema.String }),
  // @ts-expect-error "folder" is a field of the state schema
  tasks: { folder: loadMail },
  actions,
});
// throws TypeError: define: tasks.folder is also a field of the state schema

A task tag equal to a declared action or output tag:

define({
  props: Schema.Struct({}),
  state: Schema.Struct({}),
  // @ts-expect-error tag "LoadMailResolved" is also declared in "outputs"
  tasks: { subjects: loadMail },
  actions,
  outputs: Action.output("LoadMailResolved"),
});
// throws TypeError: define: tag "LoadMailResolved" of tasks.subjects is also declared in "actions" or "outputs"

One operation under two keys:

define({
  props: Schema.Struct({}),
  state: Schema.Struct({}),
  // @ts-expect-error one operation under two keys
  tasks: { inbox: loadMail, sent: loadMail },
  actions,
});
// throws TypeError: define: one Task operation is under two keys, "inbox" and "sent"

One operation in both tasks and actions, which the tag rule catches:

define({
  props: Schema.Struct({}),
  state: Schema.Struct({}),
  // @ts-expect-error tag "LoadMailResolved" is also declared in "actions"
  tasks: { subjects: loadMail },
  actions: [actions, loadMail],
});
// throws TypeError: define: tag "LoadMailResolved" of tasks.subjects is also declared in "actions" or "outputs"

A Task.output operation, whose actions never reach the fold that would write the field:

const shout = Task.output("Shout", { success: Schema.String });

define({
  props: Schema.Struct({}),
  state: Schema.Struct({}),
  // @ts-expect-error a Task.output operation is outbound
  tasks: { shout },
  actions,
});
// throws TypeError: define: tasks.shout is a Task.output operation; its actions never reach the fold that would write the field

A value that is not an operation:

define({
  props: Schema.Struct({}),
  state: Schema.Struct({}),
  // @ts-expect-error a message is not a Task operation
  tasks: { subjects: actions.Opened },
  actions,
});
// throws TypeError: define: tasks.subjects is not a Task operation

TaskOperation

interface TaskOperation<Name, Success, Failure, Input, R, Ch> {
  readonly actions: readonly [ResolvedMessage, RejectedMessage];
  readonly Resolved: ResolvedMessage;
  readonly Rejected: RejectedMessage;
  readonly run: (input: Input) => Command<TaskAction<...>, R>;
  readonly cancel: Command<TaskAction<...>>;
  readonly schema: TaskSchema<Success, Failure>;

  // internal operations only
  readonly into: <Key extends string>(key: Key) => TaskHandlers<Name, Key, Success, Failure>;
  readonly resolvedInto: <Key, Snap, N>(
    key: Key,
    then: (value: Success["Type"], snapshot: Snap) => N,
  ) => (payload: { readonly value: Success["Type"] }, snapshot: Snap) => N;
  readonly rejectedInto: <Key, Snap, N>(
    key: Key,
    then: (error: Failure["Type"], snapshot: Snap) => N,
  ) => (payload: { readonly error: Failure["Type"] }, snapshot: Snap) => N;
}

The operation holds no state. In the tasks slot the fold writes its field; on the manual path the feature's reducer writes it. into, resolvedInto and rejectedInto are absent on Task.output: an announced operation has no reducer handler to write.

const operation: TaskOperation<"LoadMail", typeof Subjects, Schema.String, string, MailApi> =
  loadMail;

console.log(Object.keys(operation).sort());
// => ["Rejected", "Resolved", "actions", "cancel", "into", "rejectedInto", "resolvedInto", "run", "schema"]

The manual path

For work whose result is not one field, the operation goes into the actions slot and the reducer writes the field by hand: Task.start to start it, into or a hand-written entry to settle it. The reducer is exhaustive over the action union, so both settle handlers are required. ManualMailbox stores the same field this way.

const ManualMailbox = define({
  props: Schema.Struct({}),
  state: Schema.Struct({ folder: Schema.String, subjects: loadMail.schema, count: Schema.Number }),
  actions: [actions, loadMail],
});

const manualMailbox = ManualMailbox.create({
  initialState: ManualMailbox.initialState(() => ({ folder: "", subjects: Task.idle, count: 0 })),
  reducer: ManualMailbox.reducer({
    Opened: ({ folder }, { draft }) => {
      draft.folder = folder;
      return Task.start(draft, "subjects", loadMail.run(folder));
    },
    Cancelled: (_payload, { draft }) => {
      draft.subjects = Task.idle;
      return [draft, loadMail.cancel];
    },
    ...loadMail.into("subjects"),
    LoadMailResolved: loadMail.resolvedInto("subjects", (value, { draft }) => {
      draft.count = value.length;
      return draft;
    }),
  }),
  render: ManualMailbox.render(() => null),
});

const manual = await Effect.runPromise(
  manualMailbox.run([actions.Opened.make({ folder: "inbox" })], {
    props: {},
    hooks: {},
    layer: MailApiLayer,
  }),
);

console.log(manual.state);
// => { folder: "inbox", subjects: { _tag: "Resolved", value: ["Hello"] }, count: 1 }

actions

Two messages, tagged ${Name}Resolved with { value } and ${Name}Rejected with { error }. The operation itself goes into define's tasks slot under its field key, or into the actions slot (actions: [actions, loadMail] in ManualMailbox), and contributes both tags either way; actions is the same pair, for reading.

console.log(loadMail.actions.map((message) => message.make({ value: [], error: "" })._tag));
// => ["LoadMailResolved", "LoadMailRejected"]

type MailboxTag = TagsOf<[typeof actions, typeof loadMail]>;
const settled: MailboxTag = "LoadMailResolved";

schema

schema: TaskSchema<Success, Failure>;

The schema of a state field holding this operation's TaskValue, built from the operation's own success and failure, so the field cannot drift from the work that fills it. The tasks slot adds each field with it; ManualMailbox declares subjects: loadMail.schema by hand.

console.log(Schema.is(loadMail.schema)(Task.resolved(["Hello"])));
// => true

const Uploads = Schema.Struct({ upload: typedLoad.schema });

// @ts-expect-error the field's error is NotFound
const stringError: typeof Uploads.Type = { upload: Task.rejected("offline") };

run

With run declared in the config, op.run(input) takes that input. Without it, op.run(effect) takes the effect.

const unboundCommand = Upload.run(Effect.succeed("receipt_1"));
const boundCommand = loadMail.run("inbox");

A run that takes no input is still bound: the operation's run is called with nothing.

const refresh = Task("Refresh", {
  success: Subjects,
  run: () =>
    Effect.gen(function* () {
      const api = yield* MailApi;
      return yield* api.list("inbox");
    }),
});

const refreshCommand = refresh.run();

op.run is the raw command: it writes nothing to the field. Under "first" the runtime drops it while a run of the operation is in flight, the same as the slot's start. The handler of the triggering action returns it, so the effect's R reaches the feature's service requirements.

cancel

const stop = loadMail.cancel; // Command.cancel("Task/LoadMail")

cancel writes nothing to the field. A field left Pending after a cancel stays Pending, so on the manual path the handler that returns cancel also resets the field, as ManualMailbox's Cancelled handler does. Under "first" a stale Pending does not block the next start: the cancelled run is not in flight.

into

into: <Key extends string>(key: Key) => TaskHandlers<Name, Key, Success, Failure>;

into(key) returns the two settle handlers, keyed by the operation's own tags, spread into the reducer:

const viaInto = ManualMailbox.reducer({
  Opened: ({ folder }, { draft }) => {
    draft.folder = folder;
    return Task.start(draft, "subjects", loadMail.run(folder));
  },
  Cancelled: (_payload, { draft }) => {
    draft.subjects = Task.idle;
    return [draft, loadMail.cancel];
  },
  ...loadMail.into("subjects"),
});

Resolved writes Task.resolved(value) into key, Rejected writes Task.rejected(error), and the rest of the state is spread through unchanged. The spread site checks key against the feature's State: key must name a TaskValue<Success, Failure> field of that state, with the operation's own success and failure types. An optional field is accepted, the same as Task.start.

const byHand = ManualMailbox.reducer({
  Opened: ({ folder }, { draft }) => {
    draft.folder = folder;
    return Task.start(draft, "subjects", loadMail.run(folder));
  },
  Cancelled: (_payload, { draft }) => {
    draft.subjects = Task.idle;
    return [draft, loadMail.cancel];
  },
  LoadMailResolved: ({ value }, { draft }) => {
    draft.subjects = Task.resolved(value);
    return draft;
  },
  LoadMailRejected: ({ error }, { draft }) => {
    draft.subjects = Task.rejected(error);
    return draft;
  },
});

byHand and viaInto fold the same. A handler written after the spread replaces the generated one for that tag, the same way a later key wins in any object literal; the other generated handler still stands.

resolvedInto and rejectedInto

resolvedInto: <Key extends string, Snap extends { readonly state: TaskField<Key, ...> }, N>(
  key: Key,
  then: (value: Success["Type"], snapshot: Snap) => N,
) => (payload: { readonly value: Success["Type"] }, snapshot: Snap) => N;

rejectedInto: <Key extends string, Snap extends { readonly state: TaskField<Key, ...> }, N>(
  key: Key,
  then: (error: Failure["Type"], snapshot: Snap) => N,
) => (payload: { readonly error: Failure["Type"] }, snapshot: Snap) => N;

The manual-path settle handler for a result that means more than the field write. resolvedInto(key, then) is a ${Name}Resolved handler: it writes Task.resolved(value) into snapshot.draft[key], then calls then with the value and the same snapshot. then returns the draft or snapshot.state, alone or beside a command; either finishes as the draft, with the field written. rejectedInto is the same for ${Name}Rejected with Task.rejected(error).

The entry is written in its key's position, after ...into(key) for the other side, which is what gives snapshot and a command's dispatch the feature's own types. manualMailbox counts the subjects this way; manual above shows count: 1.

The follow-up may return a command beside the draft.

const reported: Array<string> = [];

const reporting = ManualMailbox.create({
  initialState: ManualMailbox.initialState(() => ({ folder: "", subjects: Task.idle, count: 0 })),
  reducer: ManualMailbox.reducer({
    Opened: ({ folder }, { draft }) => {
      draft.folder = folder;
      return Task.start(draft, "subjects", loadMail.run(folder));
    },
    Cancelled: (_payload, { draft }) => {
      draft.subjects = Task.idle;
      return [draft, loadMail.cancel];
    },
    ...loadMail.into("subjects"),
    LoadMailRejected: loadMail.rejectedInto("subjects", (error, { draft }) => {
      draft.count = 0;
      return [draft, Command.effect(() => Effect.sync(() => reported.push(error)))];
    }),
  }),
  render: ManualMailbox.render(() => null),
});

const offline = await Effect.runPromise(
  reporting.run([actions.Opened.make({ folder: "inbox" })], {
    props: {},
    hooks: {},
    layer: Layer.succeed(MailApi)({ list: () => Effect.fail(new Error("offline")) }),
  }),
);

console.log(offline.state.subjects);
// => { _tag: "Rejected", error: "offline" }
console.log(reported);
// => ["offline"]

A then that has nothing to add returns snapshot.state, and the field write still lands.

const stateAfterWrite = ManualMailbox.create({
  initialState: ManualMailbox.initialState(() => ({ folder: "", subjects: Task.idle, count: 0 })),
  reducer: ManualMailbox.reducer({
    Opened: ({ folder }, { draft }) => {
      draft.folder = folder;
      return Task.start(draft, "subjects", loadMail.run(folder));
    },
    Cancelled: (_payload, { draft }) => {
      draft.subjects = Task.idle;
      return [draft, loadMail.cancel];
    },
    ...loadMail.into("subjects"),
    LoadMailResolved: loadMail.resolvedInto("subjects", (_value, { state }) => state),
  }),
  render: ManualMailbox.render(() => null),
});

const kept = stateAfterWrite.reduce(
  { _tag: "LoadMailResolved", value: ["Hello"] },
  { state: { folder: "inbox", subjects: Task.pending, count: 0 }, props: {}, hooks: {} },
);

console.log(Next.state(kept).subjects);
// => { _tag: "Resolved", value: ["Hello"] }

The field is already written into the draft when then runs, so returning any other state (a spread of state, here) is the fold's TypeError, on the finishing rules.

const spreadAfterWrite = ManualMailbox.create({
  initialState: ManualMailbox.initialState(() => ({ folder: "", subjects: Task.idle, count: 0 })),
  reducer: ManualMailbox.reducer({
    Opened: ({ folder }, { draft }) => {
      draft.folder = folder;
      return Task.start(draft, "subjects", loadMail.run(folder));
    },
    Cancelled: (_payload, { draft }) => {
      draft.subjects = Task.idle;
      return [draft, loadMail.cancel];
    },
    ...loadMail.into("subjects"),
    LoadMailResolved: loadMail.resolvedInto("subjects", (value, { state }) => ({
      ...state,
      count: value.length,
    })),
  }),
  render: ManualMailbox.render(() => null),
});

spreadAfterWrite.reduce(
  { _tag: "LoadMailResolved", value: ["Hello"] },
  { state: { folder: "inbox", subjects: Task.pending, count: 0 }, props: {}, hooks: {} },
);
// throws TypeError: handler wrote into snapshot.draft and returned a different state

Task.output

const announceUpload = Task.output("Announce", { success: Schema.String });

const Announcer = define({
  props: Schema.Struct({}),
  state: Schema.Struct({ note: Schema.String }),
  actions: actions.Opened,
  outputs: announceUpload,
});

console.log(Object.keys(announceUpload).sort());
// => ["Rejected", "Resolved", "actions", "cancel", "run", "schema"]

The same operation with both actions on the outbound channel, so it goes into the outputs slot. They leave through onAnnounceResolved and onAnnounceRejected and never reach the reducer, so the operation has no field, no place in the tasks slot, and no into, resolvedInto or rejectedInto.

The group

Every mode books fibers under `Task/${Name}`, so cancel addresses them all. The Task/ prefix keeps a feature action tagged LoadMail from sharing an address with this operation.

const groups: ReadonlyArray<string> = ["Task/LoadMail", "Task/TypedLoad"];

Group rules are in Commands.

TaskOnError and Task.errorMessage

type TaskOnError<Failure> = (cause: Cause.Cause<unknown>) => Failure;
Task.errorMessage: TaskOnError<string>;

onError receives the whole Cause, so both a typed failure and a defect map to Failure. loadMail declares no onError, so Task.errorMessage maps both.

const failed = await Effect.runPromise(
  mailbox.run([actions.Opened.make({ folder: "inbox" })], {
    props: {},
    hooks: {},
    layer: Layer.succeed(MailApi)({ list: () => Effect.fail(new Error("offline")) }),
  }),
);

console.log(failed.state.subjects);
// => { _tag: "Rejected", error: "offline" }

const died = await Effect.runPromise(
  mailbox.run([actions.Opened.make({ folder: "inbox" })], {
    props: {},
    hooks: {},
    layer: Layer.succeed(MailApi)({ list: () => Effect.die(new Error("bug")) }),
  }),
);

console.log(died.state.subjects);
// => { _tag: "Rejected", error: "bug" }

A defect lands in the field as a rejection and does not reach the Error lifecycle handler. Use Cause.hasDies in onError to tell the two apart.

Task.errorMessage is the error's message, or its name when the message is empty. A Schema.TaggedError declared without a message field is an Error whose message is "", and its name is the tag, so the field names the error instead of showing nothing. The mapping does not read cause. When the wrapped error carries the text the UI wants, say so in onError.

class MailApiError extends Schema.TaggedError<MailApiError>()("MailApiError", {
  cause: Schema.Defect(),
}) {}

const tagged = await Effect.runPromise(
  mailbox.run([actions.Opened.make({ folder: "inbox" })], {
    props: {},
    hooks: {},
    layer: Layer.succeed(MailApi)({
      list: () => Effect.fail(new MailApiError({ cause: new TypeError("Failed to fetch") })),
    }),
  }),
);

console.log(tagged.state.subjects);
// => { _tag: "Rejected", error: "MailApiError" }

const unwrapped = Task("LoadMailUnwrapped", {
  success: Subjects,
  onError: (cause) => {
    const error = Cause.squash(cause);
    return error instanceof MailApiError
      ? Task.errorMessage(Cause.fail(error.cause))
      : Task.errorMessage(cause);
  },
});

Interruption is the one cause onError never sees. Cancelled work dispatches nothing: cancelledRun above emitted [], and a take-latest interruption leaves no rejection behind either.

TaskValue

type TaskValue<Success, Failure> =
  | { readonly _tag: "Idle" }
  | { readonly _tag: "Pending" }
  | { readonly _tag: "Resolved"; readonly value: Success }
  | { readonly _tag: "Rejected"; readonly error: Failure };

Pending holds no value. There is no case that keeps the last value readable while a refetch is pending.

Task.schema

Task.schema(success: Schema.Top): TaskSchema<Success, Schema.String>
Task.schema(success: Schema.Top, failure: Schema.Top): TaskSchema<Success, Failure>

The schema of a TaskValue field that no operation owns. The failure defaults to Schema.String, to pair with Task.errorMessage. A field an operation fills on the manual path is declared with that operation's schema.

const State = Schema.Struct({
  subjects: Task.schema(Subjects),
  upload: Task.schema(Schema.String, NotFound),
});

The handlers are what connect such a field to an operation.

Task.idle and Task.pending

const initial: TaskValue<ReadonlyArray<string>, string> = Task.idle;
console.log(Task.idle);
// => { _tag: "Idle" }
console.log(Task.pending);
// => { _tag: "Pending" }

Task.idle is the initial value for a field: the tasks slot fills its fields with it, and a manual-path initialState writes it. Task.pending is written on the fold that issues the command, so a button is already disabled when the click handler returns.

Task.start

Task.start<State, Key extends TaskKeys<State>, Action, R>(
  state: State,
  key: Key,
  command: Command<Action, R> | LazyCommand<State, Action, R>,
): readonly [State, Command<Action, R> | LazyCommand<State, Action, R>]

The manual-path start. Task.start returns one tuple: the state with Pending written into key, and the command. key is constrained to the state's own TaskValue fields, so a typo is a compile error.

const started = Task.start(
  { folder: "inbox", subjects: Task.idle },
  "subjects",
  loadMail.run("inbox"),
);

console.log(started[0]);
// => { folder: "inbox", subjects: { _tag: "Pending" } }

// @ts-expect-error "folder" is not a TaskValue field
const typo = Task.start({ folder: "inbox", subjects: Task.idle }, "folder", loadMail.run("inbox"));

Inside a handler, Task.start is usually called on draft: it writes Pending into the draft in place and returns the draft in the tuple, so the fold finishes it like any other write. manualMailbox's Opened handler does this.

const openedFromDraft = manualMailbox.reduce(actions.Opened.make({ folder: "inbox" }), {
  state: { folder: "", subjects: Task.idle, count: 0 },
  props: {},
  hooks: {},
});

console.log(Next.state(openedFromDraft));
// => { folder: "inbox", subjects: { _tag: "Pending" }, count: 0 }

The command may be lazy. The thunk receives the state with Pending already written.

const lazyStart = Task.start({ folder: "inbox", subjects: Task.idle }, "subjects", (next) =>
  loadMail.run(next.folder),
);

Task.resolved and Task.rejected

The two constructors a settle writes.

console.log(Task.resolved(["Hello"]));
// => { _tag: "Resolved", value: ["Hello"] }
console.log(Task.rejected("offline"));
// => { _tag: "Rejected", error: "offline" }

Reading a TaskValue

Task.match

Task.match<Success, Failure, Cases>(
  value: TaskValue<Success, Failure>,
  cases: { Idle; Pending; Resolved; Rejected },
): TaskMatched<Cases>

Task.match is exhaustive: a missing case does not compile. Each case receives the whole member, and the result is the union of the case return types.

const label = Task.match(Task.resolved(["Hello"]), {
  Idle: () => 0,
  Pending: () => "loading",
  Resolved: ({ value }) => value.length,
  Rejected: ({ error }) => error,
});

console.log(label);
// => 1
// @ts-expect-error the Rejected case is missing
const partial = Task.match(Task.idle as TaskValue<ReadonlyArray<string>, string>, {
  Idle: () => null,
  Pending: () => null,
  Resolved: () => null,
});

Task.value, Task.error and Task.getOrElse

Reads of one case, for a reducer or a guard.

const resolved: TaskValue<ReadonlyArray<string>, string> = Task.resolved(["Hello"]);

console.log(Option.isSome(Task.value(resolved)));
// => true
console.log(Option.isNone(Task.error(resolved)));
// => true
console.log(Task.getOrElse(resolved, () => [] as ReadonlyArray<string>));
// => ["Hello"]

Task.value is Option.some(value) for Resolved and Option.none() otherwise. Task.error is Option.some(error) for Rejected and Option.none() otherwise. Task.getOrElse reads the value or calls the fallback.

Guards

Four guards, each narrowing to one case.

const current: TaskValue<ReadonlyArray<string>, string> = failed.state.subjects;

console.log(Task.isIdle(current));
// => false
console.log(Task.isPending(current));
// => false
console.log(Task.isResolved(current));
// => false
console.log(Task.isRejected(current));
// => true

const size = Task.isResolved(current) ? current.value.length : 0;
console.log(size);
// => 0
Edit this page on GitHub