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.
Every snippet on this page builds on one feature: a photo search that queries a
PhotoApi, holds the result in state.photos, and can be cancelled.
import { Cause, Context, Effect, Layer, Option, Schema } from "effect";
import { Action, Command, define, Next, Task } from "@wych/react";
import type { TaskMode, TaskOnError, TaskOperation, TaskValue } from "@wych/react";
class PhotoApi extends Context.Service<
PhotoApi,
{ readonly search: (query: string) => Effect.Effect<ReadonlyArray<string>, Error> }
>()("PhotoApi") {}
const PhotoApiLayer = Layer.succeed(PhotoApi)({ search: () => Effect.succeed(["a.jpg"]) });
const Photos = Schema.Array(Schema.String);
const photoSearch = Task("PhotoSearch", {
success: Photos,
onError: Task.message,
run: (query: string) => Effect.flatMap(PhotoApi, (api) => api.search(query)),
});
const Searched = Action("Searched", { query: Schema.String });
const Cancelled = Action("Cancelled", {});
const Gallery = define({
props: Schema.Struct({}),
state: Schema.Struct({ query: Schema.String, photos: Task.schema(Photos) }),
action: Action.of([Searched, Cancelled, ...photoSearch.actions]),
});
export const gallery = Gallery.create({
initialState: Gallery.initialState(() => ({ query: "", photos: Task.idle })),
reducer: Gallery.reducer({
Searched: ({ query }, { state }) =>
Task.start({ ...state, query }, "photos", photoSearch.run(query)),
Cancelled: (_payload, { state }) => [{ ...state, photos: Task.idle }, photoSearch.cancel],
PhotoSearchResolved: ({ value }, { state }) => ({ ...state, photos: Task.resolved(value) }),
PhotoSearchRejected: ({ error }, { state }) => ({ ...state, photos: Task.rejected(error) }),
}),
render: Gallery.render(({ state }) =>
Task.match(state.photos, {
Idle: () => null,
Pending: () => <p>Searching</p>,
Resolved: ({ value }) => (
<ul>
{value.map((url) => (
<li key={url}>{url}</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 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: { success; failure: Failure; onError: TaskOnError<Failure["Type"]>; mode?; run? },
): TaskOperation<Name, Success, Failure, Input, R>The first overload defaults failure to Schema.String and pairs with
Task.message. The second takes a failure schema and an onError that
produces its type.
const NotFound = Schema.Struct({ status: Schema.Number, message: Schema.String });
const typedSearch = Task("TypedSearch", {
success: Photos,
failure: NotFound,
onError: (cause): typeof NotFound.Type => ({
status: Cause.hasDies(cause) ? 500 : 404,
message: String(Cause.squash(cause)),
}),
run: (query: string) => Effect.flatMap(PhotoApi, (api) => api.search(query)),
});name must be capitalized, because it prefixes two action tags.
// @ts-expect-error "photoSearch" is not Capitalize<string>
const lowercase = Task("photoSearch", { success: Photos, onError: Task.message });TaskOperation
interface TaskOperation<Name, Success, Failure, Input, R, Ch> {
readonly actions: readonly [ResolvedMessage, RejectedMessage];
readonly run: (input: Input) => Command<TaskAction<...>, R>;
readonly cancel: Command<TaskAction<...>>;
}Three members, and nothing state-shaped. Where the result lands is the feature's business.
actions
Two messages, tagged ${Name}Resolved with { value } and ${Name}Rejected
with { error }. Spread them into the feature's vocabulary.
console.log(photoSearch.actions.map((message) => message.make({ value: [], error: "" })._tag));
// => ["PhotoSearchResolved", "PhotoSearchRejected"]
const vocabulary = Action.of([Searched, Cancelled, ...photoSearch.actions]);
console.log(Object.keys(vocabulary.cases).sort());
// => ["Cancelled", "PhotoSearchRejected", "PhotoSearchResolved", "Searched"]The reducer writes the result itself, which is where a handler can derive something else from it.
run
With run declared in the config, op.run(input) takes that input. Without
it, op.run(effect) takes the effect.
const unbound = Task("Upload", { success: Schema.String, onError: Task.message });
const unboundCommand = unbound.run(Effect.succeed("receipt_1"));
const boundCommand = photoSearch.run("cats");A run that takes no input is still bound: the operation's run is called
with nothing.
const refresh = Task("Refresh", {
success: Photos,
onError: Task.message,
run: () => Effect.flatMap(PhotoApi, (api) => api.search("")),
});
const refreshCommand = refresh.run();run is returned from the triggering action's handler, which is what keeps the
effect's R visible to the feature's service requirements.
cancel
const stop = photoSearch.cancel; // Command.cancel("Task/PhotoSearch")Cancelling writes nothing. A cancelled task left Pending is a permanently
disabled button, so clear the field in the same return, as the Cancelled
handler above does.
Task.output
const announceUpload = Task.output("Announce", { success: Schema.String, onError: Task.message });
const Announcer = define({
props: Schema.Struct({}),
state: Schema.Struct({ note: Schema.String }),
action: Action.of([Searched]),
output: Action.of([...announceUpload.actions]),
});The same operation with both actions on the outbound channel. They leave
through onAnnounceResolved and onAnnounceRejected and never reach the
reducer.
TaskMode
type TaskMode = "latest" | "every";"latest" is the default and uses Command.restart: a second run interrupts
the first. "every" uses Command.keyed: both runs go to completion and the
last to settle wins.
const everySearch = Task("EverySearch", {
success: Photos,
onError: Task.message,
mode: "every" satisfies TaskMode,
run: (query: string) => Effect.flatMap(PhotoApi, (api) => api.search(query)),
});Take-first is a guard in the handler, because it is a question about state.
const takeFirst = Gallery.reducer({
Searched: ({ query }, { state }) =>
Task.isPending(state.photos)
? state
: Task.start({ ...state, query }, "photos", photoSearch.run(query)),
Cancelled: (_payload, { state }) => [{ ...state, photos: Task.idle }, photoSearch.cancel],
PhotoSearchResolved: ({ value }, { state }) => ({ ...state, photos: Task.resolved(value) }),
PhotoSearchRejected: ({ error }, { state }) => ({ ...state, photos: Task.rejected(error) }),
});The group
Both modes book fibers under `Task/${Name}`, so cancel addresses them
all. The namespace prefix keeps a feature action tagged PhotoSearch from
sharing an address with this operation.
const groups: ReadonlyArray<string> = ["Task/PhotoSearch", "Task/TypedSearch"];Group rules are in Commands.
TaskOnError and Task.message
type TaskOnError<Failure> = (cause: Cause.Cause<unknown>) => Failure;
Task.message: TaskOnError<string>;onError is mandatory. It receives the whole Cause, so both a typed failure
and a defect map to Failure.
const failed = await Effect.runPromise(
gallery.run([Searched.make({ query: "cats" })], {
props: {},
hooks: {},
layer: Layer.succeed(PhotoApi)({ search: () => Effect.fail(new Error("offline")) }),
}),
);
console.log(failed.state.photos);
// => { _tag: "Rejected", error: "offline" }
const died = await Effect.runPromise(
gallery.run([Searched.make({ query: "cats" })], {
props: {},
hooks: {},
layer: Layer.succeed(PhotoApi)({ search: () => Effect.die(new Error("bug")) }),
}),
);
console.log(died.state.photos);
// => { _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.
Interruption is the one cause onError never sees. Cancelled work dispatches
nothing.
const SlowApiLayer = Layer.succeed(PhotoApi)({
search: () => Effect.as(Effect.sleep("50 millis"), ["a.jpg"] as ReadonlyArray<string>),
});
const cancelledRun = await Effect.runPromise(
gallery.run([Searched.make({ query: "cats" }), Cancelled.make({})], {
props: {},
hooks: {},
layer: SlowApiLayer,
}),
);
console.log(cancelledRun.state.photos);
// => { _tag: "Idle" }TaskValue
type TaskValue<Success, Failure> =
| { readonly _tag: "Idle" }
| { readonly _tag: "Pending" }
| { readonly _tag: "Resolved"; readonly value: Success }
| { readonly _tag: "Rejected"; readonly error: Failure };Pending drops any previous value. A refetch that keeps the last result
readable needs a fifth case, which nothing here provides.
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 state field holding a TaskValue. The failure defaults to
Schema.String, to pair with Task.message.
const State = Schema.Struct({
photos: Task.schema(Photos),
upload: Task.schema(Schema.String, NotFound),
});Nothing connects the field to an operation but the handlers you write.
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. 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>]Pending and the command as one return. key is constrained to the state's
own TaskValue fields, so a typo is a compile error.
const started = Task.start({ query: "cats", photos: Task.idle }, "photos", photoSearch.run("cats"));
console.log(started[0]);
// => { query: "cats", photos: { _tag: "Pending" } }
// @ts-expect-error "query" is not a TaskValue field
const typo = Task.start({ query: "cats", photos: Task.idle }, "query", photoSearch.run("cats"));The command may be lazy. The thunk receives the state with Pending already
written.
const lazyStart = Task.start({ query: "cats", photos: Task.idle }, "photos", (next) =>
photoSearch.run(next.query),
);Task.resolved and Task.rejected
The two constructors the Resolved and Rejected handlers write.
console.log(Task.resolved(["a.jpg"]));
// => { _tag: "Resolved", value: ["a.jpg"] }
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>Total: a missing arm does not compile. Each arm receives the whole member, and the result is the union of what the arms return.
const label = Task.match(Task.resolved(["a.jpg"]), {
Idle: () => 0,
Pending: () => "searching",
Resolved: ({ value }) => value.length,
Rejected: ({ error }) => error,
});
console.log(label);
// => 1// @ts-expect-error the Rejected arm 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
The partial reads, for a reducer or a guard.
const resolved: TaskValue<ReadonlyArray<string>, string> = Task.resolved(["a.jpg"]);
console.log(Option.isSome(Task.value(resolved)));
// => true
console.log(Option.isNone(Task.error(resolved)));
// => true
console.log(Task.getOrElse(resolved, () => [] as ReadonlyArray<string>));
// => ["a.jpg"]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.photos;
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