Actions and outputs
A message is a Schema.TaggedStruct branded with a channel. Action builds
one on the internal channel, and Action.output builds one on the outbound
channel. Action.of collects members into a vocabulary.
Every snippet on this page builds on one vocabulary: the messages of a checkout cart.
import { Schema } from "effect";
import { Action, Command, define } from "@wych/react";
import type { Emit, NoOutputs } from "@wych/react";
const Added = Action("Added", { sku: Schema.String, quantity: Schema.Number });
const Removed = Action("Removed", { sku: Schema.String });
const CheckoutRequested = Action("CheckoutRequested", {});
const OrderPlaced = Action.output("OrderPlaced", { orderId: Schema.String });
const Cancelled = Action.output("Cancelled", { reason: Schema.String });Action
Action<Tag extends Capitalize<string>, Fields extends Schema.Struct.Fields>(
tag: Tag,
fields: Fields,
): Message<Tag, Fields, "internal">An action reaches the reducer. _tag is part of the schema, so a message is
encodable and a union discriminates on it.
console.log(Object.keys(Added.fields).sort());
// => ["_tag", "quantity", "sku"]
console.log(Added.make({ sku: "sku_1", quantity: 2 }));
// => { _tag: "Added", sku: "sku_1", quantity: 2 }make fills _tag. dispatch takes the whole tagged message, and a reducer
handler receives the payload with _tag stripped.
The tag must be capitalized.
// @ts-expect-error "added" is not Capitalize<string>
const lowercase = Action("added", {});Action.output
Action.output<Tag extends Capitalize<string>, Fields extends Schema.Struct.Fields>(
tag: Tag,
fields: Fields,
): Message<Tag, Fields, "outbound">An output leaves through an on<Tag> prop and never reaches the reducer.
Command.output is the only constructor that takes one.
const announce = Command.output(OrderPlaced, { orderId: "o_1" });
// @ts-expect-error Added is an internal message
const wrongChannel = Command.output(Added, { sku: "sku_1", quantity: 1 });Action.output has a call signature and nothing else, so Action.output.of
does not exist. Build an outbound vocabulary with Action.of, which reads the
channel off its members.
Channels
The two channels are branded, so a message of one is not assignable to the other, even with the same tag and the same fields.
const InternalPing = Action("Ping", { at: Schema.Number });
const OutboundPing = Action.output("Ping", { at: Schema.Number });
// @ts-expect-error internal is not assignable to outbound
const asOutbound: typeof OutboundPing = InternalPing;
// @ts-expect-error outbound is not assignable to internal
const asInternal: typeof InternalPing = OutboundPing;Reserved lifecycle tags
Mounted, PropsChanged, HookChanged, Error and Unmounted are raised by
the runtime. Declaring one on either channel is a compile error.
// @ts-expect-error "Mounted" is a lifecycle tag
const reserved = Action("Mounted", {});
// @ts-expect-error "Unmounted" is a lifecycle tag
const reservedOutput = Action.output("Unmounted", {});Their payloads and firing order are in Lifecycle.
Action.of
Action.of<Members extends ReadonlyArray<AnyMessage<Channel>>>(
members: Members,
): Vocabulary<Members, ChannelOf<Members>>of builds a tagged union from a member list. Every member must be on one
channel; a mixed list is a compile error.
const CartActions = Action.of([Added, Removed, CheckoutRequested]);
const CartOutputs = Action.of([OrderPlaced, Cancelled]);
// @ts-expect-error the member list straddles both channels
const mixed = Action.of([Added, OrderPlaced]);cases
A record from tag to member, each with its own make.
console.log(Object.keys(CartActions.cases));
// => ["Added", "Removed", "CheckoutRequested"]
console.log(CartActions.cases.Removed.make({ sku: "sku_1" }));
// => { _tag: "Removed", sku: "sku_1" }The reducer keys off cases, so those tags are exactly the handlers a feature
owes.
guards
One type guard per tag.
const message = CartActions.cases.Added.make({ sku: "sku_1", quantity: 2 });
console.log(CartActions.guards.Added(message));
// => true
console.log(CartActions.guards.Removed(message));
// => falsematch
const describe = CartActions.match(message, {
Added: (added) => `+${added.quantity} ${added.sku}`,
Removed: (removed) => `-${removed.sku}`,
CheckoutRequested: () => "checkout",
});
console.log(describe);
// => "+2 sku_1"match receives the whole member, _tag included. A reducer handler receives
the payload instead.
Nesting
A vocabulary is itself a member. of flattens the inner cases into the outer
union, so a tag from an inner vocabulary is constructible and discriminable at
the outer one.
const AsyncActions = Action.of([
Action("Started", {}),
Action("Failed", { reason: Schema.String }),
]);
const AllActions = Action.of([AsyncActions, CheckoutRequested]);
console.log(Object.keys(AllActions.cases).sort());
// => ["CheckoutRequested", "Failed", "Started"]
console.log(AllActions.cases.Failed.make({ reason: "network" }));
// => { _tag: "Failed", reason: "network" }This is how a task contributes its two generated
actions: Action.of([CheckoutRequested, ...checkout.actions]).
Emit and NoOutputs
type Emit<A extends AnyVocabulary<"internal">, O extends AnyVocabulary<"outbound">> =
MemberOf<A> | MemberOf<O>;
type NoOutputs = Vocabulary<readonly [], "outbound">;Emit is what a command may emit and what render's dispatch accepts: the
declared actions and the declared outputs. NoOutputs is the empty outbound
vocabulary, which is define's default when no output is declared. Its
Type is never, so OutputProps degrades to {}.
type CartMessage = Emit<typeof CartActions, typeof CartOutputs>;
type LeafMessage = Emit<typeof CartActions, NoOutputs>;
const Cart = define({
props: Schema.Struct({ customerId: Schema.String }),
state: Schema.Struct({ items: Schema.Number }),
action: CartActions,
output: CartOutputs,
});
const cart = Cart.create({
initialState: Cart.initialState(() => ({ items: 0 })),
reducer: Cart.reducer({
Added: ({ quantity }, { state }) => ({ items: state.items + quantity }),
Removed: (_payload, { state }) => ({ items: state.items - 1 }),
CheckoutRequested: (_payload, { state }) => [
state,
Command.output(OrderPlaced, { orderId: "o_1" }),
],
}),
render: Cart.render(() => null),
});Two more rules bind the channels to define: an output tag equal to an action
tag is a compile error, and a prop named on<OutputTag> is a compile error.
Both are shown in Features. For the reasoning, see
Actions and outputs.