Commands
A command is data a reducer returns beside the next state. The runtime
interprets it. There is one leaf, Command.effect, and five nodes around it.
Every snippet on this page builds on one feature: a search box that queries a
SearchApi and cancels the previous query.
import { Context, Effect, Layer, Schema, Stream } from "effect";
import { Action, Command, define, Next } from "@wych/react";
import type { Command as CommandType, Dispatch, Dispatcher, Group } from "@wych/react";
class SearchApi extends Context.Service<
SearchApi,
{ readonly query: (text: string) => Effect.Effect<ReadonlyArray<string>> }
>()("SearchApi") {}
const SearchApiLayer = Layer.succeed(SearchApi)({ query: () => Effect.succeed(["one", "two"]) });
const Queried = Action("Queried", { text: Schema.String });
const Cleared = Action("Cleared", {});
const Results = Action("Results", { hits: Schema.Array(Schema.String) });
const Search = define({
props: Schema.Struct({}),
state: Schema.Struct({ text: Schema.String, hits: Schema.Array(Schema.String) }),
action: Action.of([Queried, Cleared, Results]),
});Command.none
Command.none: Command<never>The no-op, for a handler where a bare state return reads worse.
const noneReducer = Search.reducer({
Queried: ({ text }, { state }) => [{ ...state, text }, Command.none],
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { state }) => ({ ...state, hits }),
});Command.effect
Command.effect<A, R>(
effect: (dispatch: Dispatcher<A>) => Effect.Effect<unknown, never, R>,
): Command<A, R>The only leaf. It runs for effects and emits by calling dispatch, zero times,
once, or forever. The effect's error channel is never: a command that can
fail says what it does about the failure inside the effect.
const effectReducer = Search.reducer({
Queried: ({ text }, { state }) => [
{ ...state, text },
Command.effect((dispatch) =>
Effect.flatMap(SearchApi, (api) => api.query(text)).pipe(
Effect.flatMap((hits) => dispatch({ _tag: "Results", hits })),
),
),
],
Cleared: (_payload, { state }) => ({ ...state, hits: [] }),
Results: ({ hits }, { state }) => ({ ...state, hits }),
});A command that emits nothing ignores the parameter. R travels out of the
effect, so the services a feature needs are read off its reducer's return
types.
// @ts-expect-error the effect's error channel must be never
const failing = Command.effect(() => Effect.fail("boom"));
// @ts-expect-error the leaf takes a callback that returns an effect
const notACallback = Command.effect(Effect.void);A long-lived source is a leaf that never settles:
const subscription = Command.effect<{
readonly _tag: "Results";
readonly hits: ReadonlyArray<string>;
}>((dispatch) =>
Stream.runForEach(Stream.make(["one"], ["two"]), (hits) => dispatch({ _tag: "Results", hits })),
);Dispatcher and Dispatch
type Dispatcher<A> = (action: A) => Effect.Effect<void>;
type Dispatch<A> = (action: A) => void;Dispatcher is what a command's effect receives. It returns an Effect, so it
composes with the effect that calls it. Dispatch is what render and
useFeature receive. It returns void and is called from an event handler.
const dispatchRender = Search.render(({ state, dispatch }) => {
const send: Dispatch<{ readonly _tag: "Cleared" }> = dispatch;
return <button onClick={() => send({ _tag: "Cleared" })}>Clear {state.text}</button>;
});Command.keyed
Command.keyed(key: string): <A, R>(command: Command<A, R>) => Command<A, R>
Command.keyed<A, R>(key: string, command: Command<A, R>): Command<A, R>keyed names the group a command's fibers book under. It does nothing else: no
interrupting, no deferring, no serialising.
const keyedReducer = Search.reducer({
Queried: ({ text }, { state }) => [
{ ...state, text },
Command.keyed(
"query",
Command.effect((dispatch) =>
Effect.flatMap(SearchApi, (api) => api.query(text)).pipe(
Effect.flatMap((hits) => dispatch({ _tag: "Results", hits })),
),
),
),
],
Cleared: (_payload, { state }) => [{ ...state, hits: [] }, Command.cancel("query")],
Results: ({ hits }, { state }) => ({ ...state, hits }),
});Nesting resolves outermost-first: an inner keyed under an outer one changes
nothing.
Command.batch
Command.batch<A, R>(...commands: ReadonlyArray<Command<A, R>>): Command<A, R>Members are interpreted in order under one context. The one thing batch can
do that Effect.all cannot is put a cancel before the command that replaces
it. Compose effects with Effect.all inside a single leaf.
const replace = Command.batch(
Command.cancel("query"),
Command.keyed(
"query",
Command.effect(() => Effect.void),
),
);Command.cancel
Command.cancel<A = never>(target: Group): Command<A, never>cancel interrupts every fiber booked under one name. It is a command in its
own right, so one handler can invalidate work another action started.
const cancelReducer = Search.reducer({
Queried: ({ text }, { state }) => [{ ...state, text }, Command.none],
Cleared: (_payload, { state }) => [{ ...state, hits: [] }, Command.cancel("query")],
Results: ({ hits }, { state }) => ({ ...state, hits }),
});Command.cancel("Queried") reaches only the unkeyed fibers of the Queried
tag. Keyed work answers to its own name.
Command.restart
Command.restart(name: Group): <A, R>(command: Command<A, R>) => Command<A, R>
Command.restart<A, R>(name: Group, command: Command<A, R>): Command<A, R>Take-latest as one word. restart(name, command) is exactly
batch(cancel(name), keyed(name, command)). The interpreter and devtools see
the desugared batch.
const takeLatest = Search.reducer({
Queried: ({ text }, { state }) => [
{ ...state, text },
Command.restart(
"query",
Command.effect((dispatch) =>
Effect.sleep("300 millis").pipe(
Effect.andThen(Effect.flatMap(SearchApi, (api) => api.query(text))),
Effect.flatMap((hits) => dispatch({ _tag: "Results", hits })),
),
),
),
],
Cleared: (_payload, { state }) => [{ ...state, hits: [] }, Command.cancel("query")],
Results: ({ hits }, { state }) => ({ ...state, hits }),
});The desugaring is visible on the value: restart returns a Batch.
const desugaredRestart = Command.restart("query", Command.none);
console.log(desugaredRestart._tag);
// => "Batch"The debounce above is written with Effect combinators. Concurrency policy lives inside the effect. The runtime owns naming and cancelling. See Debounce and take-latest.
Command.output
Command.output<Tag, Fields>(
message: Message<Tag, Fields, "outbound">,
payload: Omit<Schema.Struct<Fields>["Type"], "_tag">,
): Command<{ readonly _tag: Tag } & ...>Emits an outbound message, which leaves through its on<Tag> prop. Passing an
internal message is a compile error, shown in
Actions and outputs.
const Picked = Action.output("Picked", { hit: Schema.String });
const WithOutput = define({
props: Schema.Struct({}),
state: Schema.Struct({ text: Schema.String, hits: Schema.Array(Schema.String) }),
action: Action.of([Queried, Cleared, Results]),
output: Action.of([Picked]),
});
const outputReducer = WithOutput.reducer({
Queried: ({ text }, { state }) => [{ ...state, text }, Command.output(Picked, { hit: text })],
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { state }) => ({ ...state, hits }),
});Group
type Group = string;One flat namespace per mount. The booking address of a command is key ?? tag:
a keyed command books under its key, and an unkeyed command books under its
issuing action's tag.
const addresses: ReadonlyArray<Group> = ["query", "Queried"];
const grouped = Search.reducer({
// books under "Queried", the issuing action's tag
Queried: ({ text }, { state }) => [{ ...state, text }, Command.effect(() => Effect.void)],
// books under "query"
Cleared: (_payload, { state }) => [
state,
Command.keyed(
"query",
Command.effect(() => Effect.void),
),
],
Results: ({ hits }, { state }) => ({ ...state, hits }),
});A key equal to some action's tag is deliberate sharing: one namespace means
one meaning per name. A task books under
Task/${Name} for that reason. See
Groups and cancellation.
Pipeable
Every command is Pipeable, so keyed and restart work in their curried
form.
const piped = Command.effect(() => Effect.void).pipe(Command.keyed("query"));
const pipedRestart = Command.none.pipe(Command.restart("query"));Contextual typing
A has no inference site of its own. Inside a handler's return, dispatch's
action type comes from the contextual type of that return. Written standalone,
A falls back to never, so name it with a type argument.
const named = Command.effect<{ readonly _tag: "Results"; readonly hits: ReadonlyArray<string> }>(
(dispatch) => dispatch({ _tag: "Results", hits: [] }),
);R defaults to never on the same terms, so a standalone leaf that needs a
service names both type arguments.
const namedWithService = Command.effect<
{ readonly _tag: "Results"; readonly hits: ReadonlyArray<string> },
SearchApi
>((dispatch) =>
Effect.flatMap(SearchApi, (api) => api.query("cats")).pipe(
Effect.flatMap((hits) => dispatch({ _tag: "Results", hits })),
),
);A .pipe receiver is checked before the contextual type of the .pipe call
exists, so a leaf that dispatches loses A through .pipe. Use the
two-argument form of keyed or restart there.
const contextual = Search.reducer({
Queried: ({ text }, { state }) => [
{ ...state, text },
// @ts-expect-error dispatch is typed never through .pipe
Command.effect((dispatch) => dispatch({ _tag: "Results", hits: [] })).pipe(
Command.keyed("query"),
),
],
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { state }) => ({ ...state, hits }),
});Command.cancel is Command<never>, and Command is covariant in A, so a
cancel written first in a batch does not pin the batch to never.
const cancelFirst = Search.reducer({
Queried: ({ text }, { state }) => [
{ ...state, text },
Command.batch(
Command.cancel("query"),
Command.keyed(
"query",
Command.effect((dispatch) => dispatch({ _tag: "Results", hits: [] })),
),
),
],
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { state }) => ({ ...state, hits }),
});What does not exist
Command.stream, Command.ignore, Command.queue and a Policy vocabulary
were removed. The constructor set is exactly none, effect, keyed,
batch, cancel, restart and output.
const constructors: ReadonlyArray<keyof typeof Command> = [
"none",
"effect",
"keyed",
"batch",
"cancel",
"restart",
"output",
];Why the model is shaped this way: Commands as data.