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 } 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 actions = Action({
Queried: { text: Schema.String },
Cleared: {},
Results: { hits: Schema.Array(Schema.String) },
});
const Search = define({
props: Schema.Struct({}),
state: Schema.Struct({ text: Schema.String, hits: Schema.Array(Schema.String) }),
actions,
});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 }, { draft }) => {
draft.text = text;
return [draft, Command.none];
},
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});Command.effect
Command.effect<A = never, R = never>(
effect: (dispatch: Dispatcher<A>) => Effect.Effect<unknown, never, R>,
): Command<A, R>
Command.effect<const S extends MemberSource<Channel>, R = never>(
source: S,
effect: (dispatch: Dispatcher<MembersOf<S>>) => Effect.Effect<unknown, never, R>,
): Command<MembersOf<S>, R>The only leaf. The effect runs and emits by calling dispatch: zero times,
once, or forever. The effect's error channel is never, so the effect handles
its own failures before it returns. The second overload takes a source
first: a message, a record, a Task or an array of those, the same value a
define slot takes. The source types dispatch and is not stored; see
Contextual typing.
const effectReducer = Search.reducer({
Queried: ({ text }, { draft }) => {
draft.text = text;
return [
draft,
Command.effect((dispatch) =>
Effect.gen(function* () {
const api = yield* SearchApi;
const hits = yield* api.query(text);
yield* dispatch(actions.Results, { hits });
}),
),
];
},
Cleared: (_payload, { draft }) => {
draft.hits = [];
return draft;
},
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});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 source that outlives one fold, such as a websocket or a stream of ticks, is a subscription, not a command; see Subscriptions.
Dispatcher and Dispatch
interface Dispatcher<A> {
<M extends MessageOf<A>>(message: M, ...payload: PayloadArgs<M>): Effect.Effect<void>;
(action: A): Effect.Effect<void>;
}
interface Dispatch<A> {
<M extends MessageOf<A>>(message: M, ...payload: PayloadArgs<M>): void;
(action: A): void;
}
type MessageOf<A> = AnyMessage<Channel> & { readonly Type: A }; // a schema whose values A admits
type PayloadOf<M> = Omit<M["~type.make.in"], "_tag">; // what M.make takes
type PayloadArgs<M> = {} extends PayloadOf<M> ? [payload?: PayloadOf<M>] : [payload: PayloadOf<M>];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.
Both take a message schema and its payload, or a built message.
dispatch(actions.Results, { hits }) is dispatch(actions.Results.make({ hits })).
The payload argument is optional when every field of the message is optional,
so dispatch(actions.Cleared) needs none.
const search = Search.create({
initialState: Search.initialState(() => ({ text: "", hits: [] })),
reducer: Search.reducer({
Queried: ({ text }, { draft }) => {
draft.text = text;
return [
draft,
Command.effect((dispatch) =>
Effect.gen(function* () {
const api = yield* SearchApi;
const hits = yield* api.query(text);
yield* dispatch(actions.Results, { hits });
if (hits.length === 0) yield* dispatch(actions.Cleared);
}),
),
];
},
Cleared: (_payload, { draft }) => {
draft.hits = [];
return draft;
},
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
}),
render: Search.render(() => null),
});
const queried = await Effect.runPromise(
search.run([actions.Queried.make({ text: "cats" })], {
props: {},
hooks: {},
layer: SearchApiLayer,
}),
);
console.log(queried.state);
// => { text: "cats", hits: ["one", "two"] }
console.log(queried.emitted);
// => [{ _tag: "Results", hits: ["one", "two"] }]make validates the payload. A payload the schema rejects is a defect of the
command that sent it: Error folds when the feature handles it, and run
reports it in defects.
const malformed = Search.create({
initialState: Search.initialState(() => ({ text: "", hits: [] })),
reducer: Search.reducer({
Queried: (_payload, { state }) => [
state,
Command.effect((dispatch) => dispatch(actions.Results, { hits: "one" as never })),
],
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
}),
render: Search.render(() => null),
});
const rejected = await Effect.runPromise(
malformed.run([actions.Queried.make({ text: "cats" })], {
props: {},
hooks: {},
layer: SearchApiLayer,
}),
);
console.log(rejected.state);
// => { text: "", hits: [] }
console.log(rejected.defects.map(({ from, handled }) => ({ from, handled })));
// => [{ from: "Queried", handled: false }]Dispatch takes the same two forms from the view. A rejected payload there
throws out of the event handler; see Runtime.
const dispatchRender = Search.render(({ state, dispatch }) => {
const send: Dispatch<{ readonly _tag: "Cleared" }> = dispatch;
return <button onClick={() => send(actions.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.
It interrupts nothing, defers nothing, and serialises nothing.
const keyedReducer = Search.reducer({
Queried: ({ text }, { draft }) => {
draft.text = text;
return [
draft,
Command.keyed(
"query",
Command.effect((dispatch) =>
Effect.gen(function* () {
const api = yield* SearchApi;
const hits = yield* api.query(text);
yield* dispatch(actions.Results, { hits });
}),
),
),
];
},
Cleared: (_payload, { draft }) => {
draft.hits = [];
return [draft, Command.cancel("query")];
},
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});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. batch is the place for a
cancel that runs before the command that replaces its work. 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 }, { draft }) => {
draft.text = text;
return [draft, Command.none];
},
Cleared: (_payload, { draft }) => {
draft.hits = [];
return [draft, Command.cancel("query")];
},
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});Command.cancel("Queried") reaches only the unkeyed fibers of the Queried
tag. Keyed work is reached by its key.
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>restart is take-latest in one call. restart(name, command) is exactly
batch(cancel(name), keyed(name, command)). The interpreter and devtools see
that batch.
const takeLatest = Search.reducer({
Queried: ({ text }, { draft }) => {
draft.text = text;
return [
draft,
Command.restart(
"query",
Command.effect((dispatch) =>
Effect.gen(function* () {
yield* Effect.sleep("300 millis");
const api = yield* SearchApi;
const hits = yield* api.query(text);
yield* dispatch(actions.Results, { hits });
}),
),
),
];
},
Cleared: (_payload, { draft }) => {
draft.hits = [];
return [draft, Command.cancel("query")];
},
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});restart returns that Batch.
const desugaredRestart = Command.restart("query", Command.none);
console.log(desugaredRestart._tag);
// => "Batch"The debounce above is written with Effect combinators inside the effect. The runtime owns naming and cancelling. See Debounce and take-latest.
Command.output
Command.output<M extends AnyMessage<"outbound">>(
message: M,
...payload: PayloadArgs<M>
): Command<M["Type"]>Emits an outbound message, which leaves through its on<Tag> prop. The
payload follows the same rule as dispatch: required when a field is,
omitted when every field is optional. An internal message as the argument is
a compile error, shown in Actions and outputs.
const outputs = Action.output({ Picked: { hit: Schema.String }, Dismissed: {} });
const WithOutput = define({
props: Schema.Struct({}),
state: Schema.Struct({ text: Schema.String, hits: Schema.Array(Schema.String) }),
actions,
outputs,
});
const outputReducer = WithOutput.reducer({
Queried: ({ text }, { draft }) => {
draft.text = text;
return [draft, Command.output(outputs.Picked, { hit: text })];
},
Cleared: (_payload, { state }) => [state, Command.output(outputs.Dismissed)],
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});
// @ts-expect-error Picked has a required field
const missingPayload = Command.output(outputs.Picked);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 }, { draft }) => {
draft.text = text;
return [draft, Command.effect(() => Effect.void)];
},
// books under "query"
Cleared: (_payload, { state }) => [
state,
Command.keyed(
"query",
Command.effect(() => Effect.void),
),
],
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});A key equal to an action's tag shares that group with the action's unkeyed
commands. A task books under Task/${Name} to keep
its group apart from every action tag. 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 is inferred from the contextual type alone. Inside a handler's return,
dispatch's action type comes from the contextual type of that return. A
standalone leaf has no contextual type, so it names the messages it may emit
as its first argument, and dispatch accepts those.
const named = Command.effect(actions.Results, (dispatch) =>
dispatch(actions.Results, { hits: [] }),
);R is inferred from the effect in both forms, so a standalone leaf that
needs a service writes nothing more.
const namedWithService = Command.effect(actions.Results, (dispatch) =>
Effect.gen(function* () {
const api = yield* SearchApi;
const hits = yield* api.query("cats");
yield* dispatch(actions.Results, { hits });
}),
);A type argument names A too, but TypeScript has no partial inference: naming
A that way also names R, which then must be written out.
const typeArguments = Command.effect<typeof actions.Results.Type, SearchApi>((dispatch) =>
Effect.gen(function* () {
const api = yield* SearchApi;
const hits = yield* api.query("cats");
yield* dispatch(actions.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 }, { draft }) => {
draft.text = text;
return [
draft,
// @ts-expect-error dispatch is typed never through .pipe
Command.effect((dispatch) => dispatch(actions.Results, { hits: [] })).pipe(
Command.keyed("query"),
),
];
},
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});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 }, { draft }) => {
draft.text = text;
return [
draft,
Command.batch(
Command.cancel("query"),
Command.keyed(
"query",
Command.effect((dispatch) => dispatch(actions.Results, { hits: [] })),
),
),
];
},
Cleared: (_payload, { state }) => state,
Results: ({ hits }, { draft }) => {
draft.hits = [...hits];
return draft;
},
});The reasoning behind this shape is in Commands as data.