Features
A feature is four schemas and three functions. define declares the four,
create binds the three, and the result is a plain value with three methods:
reduce, run and subscriptions.
Every snippet on this page builds on one feature: a note editor that holds draft text and announces a saved note.
import { Effect, Layer, Schema } from "effect";
import { Action, Children, Command, define, Next, Task } from "@wych/react";
import type { LazyCommand, Next as NextType, RenderSnapshot, Snapshot } from "@wych/react";
const actions = Action({
Typed: { text: Schema.String },
Saved: {},
});
const NoteSaved = Action.output("NoteSaved", { noteId: Schema.String, text: Schema.String });
const NoteEditor = define({
props: Schema.Struct({ noteId: Schema.String, autosave: Schema.Boolean }),
state: Schema.Struct({ text: Schema.String, dirty: Schema.Boolean }),
actions,
outputs: NoteSaved,
});define
define({
props: Schema.Struct, // required
state: Schema.Struct, // required
tasks?: { readonly [key: string]: TaskOperation }, // optional, internal operations
actions: MemberSource<"internal">, // required
outputs?: MemberSource<"outbound">, // optional
useUnsafeHooks?: (props, state) => H,
}): FeatureDefinitionprops and state are Schema.Structs. actions and outputs each take a
message, a record of messages (Action({ ... })), a
task operation, or an array of those, one array
nested inside another at most. tasks takes a record of task operations,
keyed by the state field each one fills. define infers Props, State, the
messages of each slot and the hooks from that one object literal, so no type
argument is written by hand.
const OneMessage = define({
props: Schema.Struct({}),
state: Schema.Struct({}),
actions: actions.Saved,
});
const Nested = define({
props: Schema.Struct({}),
state: Schema.Struct({ text: Schema.String }),
actions: [actions, Action("Reverted")],
outputs: [NoteSaved, Action.output("Discarded")],
});The slot fixes the channel: actions takes internal messages only, outputs
outbound ones. The check is a compile error and, for a source that got past
the types, a throw at define.
define({
props: Schema.Struct({}),
state: Schema.Struct({}),
// @ts-expect-error NoteSaved is outbound and cannot be declared in "actions"
actions: [actions, NoteSaved],
});
// throws TypeError: define: "NoteSaved" is outbound and cannot be declared in "actions"Two more rules are compile errors: an output tag equal to an action tag, and
a prop named after a derived output prop. A tag declared twice across both
slots also throws at define.
const Collides = Action.output("Typed");
define({
props: Schema.Struct({}),
state: Schema.Struct({}),
actions: actions.Typed,
// @ts-expect-error output tag "Typed" collides with an action tag
outputs: Collides,
});
// throws TypeError: define: tag "Typed" is declared twice
const propCollision = define({
props: Schema.Struct({ onNoteSaved: Schema.String }),
state: Schema.Struct({}),
actions: actions.Saved,
// @ts-expect-error prop "onNoteSaved" collides with the derived output prop
outputs: NoteSaved,
});The member sources a slot takes are in
The define slots.
Children in the state schema throws at define, because state reaches a
devtools sink verbatim and an opaque value does not encode.
define({
props: Schema.Struct({}),
state: Schema.Struct({ children: Children }),
actions: actions.Saved,
});
// throws TypeError: Opaque field "children" declared in the state schemaThe full message ends with opaque declarations like Children belong in props.
tasks
tasks?: { readonly [key: string]: TaskOperation }
// State gains `readonly [key]: TaskValue<Success, Failure>` per key
// Action gains `${Name}Resolved` and `${Name}Rejected` per key
// initialState returns State without the task keysEach key of tasks adds a TaskValue field to State under that key, typed
by the operation's own schemas, and the operation's two actions to the action
union. The field starts Idle, so initialState omits the key. A handler
starts and cancels the work through snapshot.tasks.
const autosave = Task("Autosave", { success: Schema.String });
const WithTask = define({
props: Schema.Struct({}),
state: Schema.Struct({ text: Schema.String }),
tasks: { autosave },
actions,
outputs: NoteSaved,
});
const withTask = WithTask.create({
initialState: WithTask.initialState(() => ({ text: "" })),
reducer: WithTask.reducer({
Typed: ({ text }, { draft }) => {
draft.text = text;
return draft;
},
Saved: (_payload, { state, tasks }) => tasks.autosave.start(Effect.succeed(state.text)),
}),
render: WithTask.render(() => null),
});
const untouched = await Effect.runPromise(
withTask.run([], { props: {}, hooks: {}, layer: Layer.empty }),
);
console.log(untouched.state);
// => { text: "", autosave: { _tag: "Idle" } }
const autosaved = await Effect.runPromise(
withTask.run([actions.Typed.make({ text: "hi" }), actions.Saved.make()], {
props: {},
hooks: {},
layer: Layer.empty,
}),
);
console.log(autosaved.state);
// => { text: "hi", autosave: { _tag: "Resolved", value: "hi" } }The AutosaveResolved and AutosaveRejected handlers are optional: the fold
writes the field before either runs. The slot's rules, snapshot.tasks and
the clash rules that throw at define are in Tasks.
useUnsafeHooks
useUnsafeHooks?: (props: Props, state: State) => HThe runtime calls this in render position on every render, so the rules of
hooks hold and useThing(id)-shaped hooks work. Its result arrives as
snapshot.hooks, and a change in any key raises
HookChanged.
const WithHooks = define({
props: Schema.Struct({ noteId: Schema.String }),
state: Schema.Struct({ text: Schema.String }),
actions: actions.Typed,
useUnsafeHooks: (props) => ({ storageKey: `note:${props.noteId}` }),
});What define returns
FeatureDefinition {
initialState(fn): (props) => InitialStateOf<State, Tasks> // State without the task keys
reducer(obj): Reducer
render(fn): Render
subscriptions(fn): SubscriptionsHook
create({ initialState, reducer, render, subscriptions? }): Feature
}initialState, reducer and render are identity functions at runtime. They
supply types, so each piece can live in its own file. NoteEditor has no
tasks, so its initialState returns the whole State.
const initialState = NoteEditor.initialState(() => ({ text: "", dirty: false }));
const reducer = NoteEditor.reducer({
Typed: ({ text }, { draft }) => {
draft.text = text;
draft.dirty = true;
return draft;
},
Saved: (_payload, { draft, props }) => {
draft.dirty = false;
return [draft, Command.output(NoteSaved, { noteId: props.noteId, text: draft.text })];
},
});
const render = NoteEditor.render(({ state, dispatch }) => (
<textarea
value={state.text}
onChange={(event) => dispatch(actions.Typed, { text: event.target.value })}
/>
));
export const noteEditor = NoteEditor.create({ initialState, reducer, render });The reducer
One handler per declared action tag, required and exhaustive. A handler is
(payload, snapshot) => Next. payload is the action with _tag stripped; the
handler key already names the tag.
A handler that writes a key the state schema does not declare is a compile error: the draft has the state's keys and no others. A handler that returns a new object, such as a spread, is not checked by the types. In development the fold decodes every new next state against the whole state schema, at every depth, under the finishing rules; see State validation.
const excess = NoteEditor.reducer({
Typed: ({ text }, { draft }) => {
draft.text = text;
// @ts-expect-error Property 'wordCount' does not exist on the draft
draft.wordCount = text.length;
return draft;
},
Saved: (_payload, { state }) => state,
});A handler for an output tag is a compile error too. Outputs have no handler.
const outputHandler = NoteEditor.reducer({
Typed: ({ text }, { draft }) => {
draft.text = text;
draft.dirty = true;
return draft;
},
Saved: (_payload, { state }) => state,
// @ts-expect-error "NoteSaved" is an output, so it has no handler
NoteSaved: (_payload, { state }) => state,
});Lifecycle handlers are optional. They are listed in Lifecycle.
subscriptions
create takes an optional fourth piece, subscriptions, for a long-lived
source declared by key rather than started by a handler. It lives beside
reducer, not inside it, and does not return a Next. See
Subscriptions for the hook, the key rule and
the diff that starts and stops fibers.
Snapshot and RenderSnapshot
interface Snapshot<Props, State, H> {
readonly state: State;
readonly props: Props;
readonly hooks: H;
}
interface RenderSnapshot<Props, State, Action, H> extends Snapshot<Props, State, H> {
readonly dispatch: Dispatch<Action>;
}A reducer handler receives a Snapshot. render and useFeature receive a
RenderSnapshot, which adds dispatch.
type EditorSnapshot = Snapshot<
{ readonly noteId: string; readonly autosave: boolean },
{ readonly text: string; readonly dirty: boolean },
{}
>;
type EditorRenderSnapshot = RenderSnapshot<
{ readonly noteId: string; readonly autosave: boolean },
{ readonly text: string; readonly dirty: boolean },
{ readonly _tag: "Typed"; readonly text: string },
{}
>;render's dispatch takes a message schema and its payload, or a built
message, and it carries the declared outputs as well, so the view can announce
an output without a mirror action. See
Runtime.
ReducerSnapshot and snapshot.draft
interface ReducerSnapshot<Props, State, H, Tasks = {}> extends Snapshot<Props, State, H> {
readonly draft: Draft<State>;
readonly tasks: TaskHandles<Tasks, State>;
}A reducer handler receives a ReducerSnapshot: state, props and hooks,
plus draft, a mutable view of state, and tasks, one handle per key of
the tasks slot. Write into the draft and return it, alone or
beside a command, and the fold replaces it with the finished value before
anything else reads the Next. A handle writes into the same draft and
returns it beside the operation's command. render and subscriptions
receive a plain Snapshot, with no draft and no tasks: neither is a place
to change state. The reducer above already writes this way.
draft and tasks are getters on the snapshot's prototype, so the own keys
stay state, props and hooks. A feature without a slot hands {} as
tasks.
let ownKeys: ReadonlyArray<string> = [];
let handles: ReadonlyArray<string> = [];
const readingHandles = NoteEditor.reducer({
Typed: (_payload, snapshot) => {
ownKeys = Object.keys(snapshot);
handles = Object.keys(snapshot.tasks);
return snapshot.state;
},
Saved: (_payload, { state }) => state,
});
NoteEditor.create({ initialState, reducer: readingHandles, render }).reduce(
actions.Typed.make({ text: "hi" }),
{ state: { text: "", dirty: false }, props: { noteId: "n_1", autosave: true }, hooks: {} },
);
console.log(ownKeys);
// => ["state", "props", "hooks"]
console.log(handles);
// => []withTask's Saved handler above uses a handle; the handle's signatures are
in Tasks.
type Draft<T> = T extends { readonly pipe: unknown }
? T // Option, Chunk, Effect and the rest of Effect's data types pass through
: T extends ReadonlyArray<infer E>
? Array<Draft<E>>
: T extends object
? { -readonly [K in keyof T]: Draft<T[K]> }
: T; // primitives and functions pass throughArrays and plain objects lose readonly, recursively, so a nested array or
record is as mutable as the top level. Same key set as State, so a draft is
assignable to State, and an excess key on it is a compile error, the same as
on a spread.
The one place the mutable type bites: a drafted array field is Array<E>,
and a ReadonlyArray<E> does not go into it. An action payload declared with
Schema.Array decodes to a ReadonlyArray, so draft.hits = hits is a
compile error where draft.hits = [...hits] is not. The copy is shallow and
the fold freezes the result either way. Task.resolved and Task.rejected
are typed for this already, so draft.results = Task.resolved(value) needs
no copy.
Finishing rules
reduce applies these for every caller, run and the store included:
- Returned the draft, wrote into it: the finished value takes its place.
Untouched siblings are the same objects as in
state, and the result is deep-frozen. - Returned the draft, wrote nothing: the finished value is
stateitself, by reference. That is the no-op. - Returned another state, wrote nothing into the draft: the returned state wins. Reading the draft costs nothing.
- Returned another state, wrote into the draft: a
TypeError. Two next states and no rule that picks one. - In development, the next state does not match the state schema, at any
depth: a
TypeErrorlisting every issue with its path. Returningstateitself runs no check.initialStateis checked the same way when the initial state is built. See State validation.
const mixedReducer = NoteEditor.reducer({
Typed: ({ text }, { state, draft }) => {
draft.dirty = true;
return { ...state, text };
},
Saved: (_payload, { state }) => state,
});
const mixedFeature = NoteEditor.create({ initialState, reducer: mixedReducer, render });
mixedFeature.reduce(actions.Typed.make({ text: "hi" }), {
state: { text: "", dirty: false },
props: { noteId: "n_1", autosave: true },
hooks: {},
});
// throws TypeError: handler wrote into snapshot.draft and returned a different stateA LazyCommand in the tuple runs at Next.command, after the fold replaced
the draft, so the thunk sees the finished state, never the proxy; the Next
section below shows it.
The proxy is revoked once the fold ends
The drafter revokes every proxy at finish. A copy that holds draft children
({ ...draft }) is unreadable once the handler returns: read what you need
before returning, or return the draft itself and let the fold finish it.
let leaked: unknown;
const leakyReducer = NoteEditor.reducer({
Typed: (_payload, { draft }) => {
leaked = draft;
return draft;
},
Saved: (_payload, { state }) => state,
});
const leakyFeature = NoteEditor.create({ initialState, reducer: leakyReducer, render });
leakyFeature.reduce(actions.Typed.make({ text: "hi" }), {
state: { text: "", dirty: false },
props: { noteId: "n_1", autosave: true },
hooks: {},
});
let threwOnRead = false;
try {
(leaked as { text: unknown }).text;
} catch {
threwOnRead = true;
}
console.log(threwOnRead);
// => trueSpread is still valid
A one-field change reads fine as a spread, and a spread handler beside a drafting one is unaffected: its result is a plain object, not frozen.
const spreadForm = NoteEditor.reducer({
Typed: ({ text }, { state }) => ({ ...state, text, dirty: true }),
Saved: (_payload, { state }) => state,
});Next
type Next<State, Action, R> = State | readonly [State, Command<Action, R> | LazyCommand<State, Action, R>];
type LazyCommand<State, Action, R> = (state: State) => Command<Action, R>;
Next.state(next): State
Next.command(next): Command | undefined
Next.lazy(state, (next) => command): readonly [State, LazyCommand]A handler returns a bare state, a [state, command] tuple, or a
[state, (next) => command] lazy tuple. The thunk receives the tuple's own
state, so a handler can write the next state inline and read it in the command
without a local name. Next.lazy names that tuple. It infers the state type
from its first argument, so the thunk sees the shape the handler built rather
than the wider state schema.
const lazyReducer = NoteEditor.reducer({
Typed: ({ text }, { draft }) => {
draft.text = text;
draft.dirty = true;
return Next.lazy(draft, (next) =>
Command.effect(() => Effect.sync(() => localStorage.setItem("draft", next.text))),
);
},
Saved: (_payload, { state }) => state,
});Next.command resolves a lazy command once, by calling it with the tuple's own
state. Next.state reads the state whichever form was returned. The thunk
above receives the finished state: the fold has already replaced draft with
its frozen value by the time next.text is read.
const bare: NextType<{ readonly text: string }, never> = { text: "hello" };
console.log(Next.state(bare));
// => { text: "hello" }
console.log(Next.command(bare));
// => undefinedFeature.reduce
feature.reduce(
action: Action | LifecycleAction<Props, H>,
snapshot: Snapshot<Props, State, H>,
drafter?: DrafterService,
): Next<State, Action | Output, R>The reducer as one pure function. No React, no Effect runtime. drafter
builds snapshot.draft; it defaults to mutativeDrafter, so a test hands one
of its own only to swap the drafting library. See
Runtime for DrafterService and how to
install a custom one at the root.
const typed = noteEditor.reduce(actions.Typed.make({ text: "hi" }), {
state: { text: "", dirty: false },
props: { noteId: "n_1", autosave: true },
hooks: {},
});
console.log(Next.state(typed));
// => { text: "hi", dirty: true }Three behaviours are specific to reduce:
- An unhandled lifecycle action returns
snapshot.stateunchanged. - For
Unmountedthe handler's returned state is replaced bysnapshot.state. Only the command survives. - A missing handler for a non-lifecycle tag throws
TypeError('No reducer handler for action "X"'). Only a call that bypasses the types can reach this.
const unhandled = noteEditor.reduce(
{ _tag: "Mounted" },
{
state: { text: "draft", dirty: true },
props: { noteId: "n_1", autosave: true },
hooks: {},
},
);
console.log(Next.state(unhandled));
// => { text: "draft", dirty: true }feature.subscriptions(snapshot) reads the same way: what a snapshot
declares, as a record, without a mount.
console.log(
Object.keys(
noteEditor.subscriptions({
state: { text: "hi", dirty: true },
props: { noteId: "n_1", autosave: true },
hooks: {},
}),
),
);
// => []noteEditor has no subscriptions hook, so every snapshot declares {}. See
Subscriptions for a feature that does.
Feature.run
feature.run(
actions: Iterable<Action | LifecycleAction<Props, H>>,
options: { readonly props: Props; readonly hooks: H; readonly layer: Layer.Layer<R> },
): Effect.Effect<{
state: State;
emitted: ReadonlyArray<Action>;
outputs: ReadonlyArray<Output>;
defects: ReadonlyArray<RunDefect>;
subscriptions: ReadonlyArray<string>;
}>run folds a sequence of built messages, interprets each command against
layer, feeds what a command emits back into the reducer, and collects what
left. make builds a seed; make() takes no argument when every field is
optional.
const result = await Effect.runPromise(
noteEditor.run([actions.Typed.make({ text: "hi" }), actions.Saved.make()], {
props: { noteId: "n_1", autosave: true },
hooks: {},
layer: Layer.empty,
}),
);
console.log(result.state);
// => { text: "hi", dirty: false }
console.log(result.emitted);
// => []
console.log(result.outputs);
// => [{ _tag: "NoteSaved", noteId: "n_1", text: "hi" }]
console.log(result.defects);
// => []The five result fields differ:
state: the state after the last fold.emitted: actions a command or a subscription emitted. Seeded actions are folded and are absent here.outputs: messages whose tag is a declared output. An output is never folded.defects: every command or subscription that died, in the order observed.subscriptions: the keys declared whenrunresolved, in record order.
run resolves at command quiescence: nothing queued, no command fiber in
flight. A command that never completes keeps run from resolving, so run
never resolves for Command.effect(() => Effect.never). That is what a
command is: work that finishes.
A subscription fiber counts for nothing toward quiescence. A long-lived
source belongs in subscriptions: run resolves with one in flight,
interrupting it and recording its key in the result. See
Subscriptions for the diff, the key rule and
how run treats a synchronous versus an asynchronous stub.
For a feature with no services pass layer: Layer.empty and hooks: {}. See
Test a feature without React.
RunDefect
interface RunDefect {
readonly from: string;
readonly error: unknown;
readonly handled: boolean;
}One command or subscription death observed by run, in the order it was
seen. from is the tag of the action whose command died, or the key of the
subscription that died. error is the squashed cause: the thrown value, or
what Effect.die was given. handled is whether the feature's Error
handler folded it: false with no handler, or when the dying command was the
Error handler's own. Interruption (Command.cancel, Command.restart, an
undeclared subscription key) is how work normally ends and is never a defect.
const Boomed = Action("Boomed");
const dying = Command.effect(() => Effect.die(new Error("kaboom")));
const flaky = define({
props: Schema.Struct({}),
state: Schema.Struct({ crashed: Schema.Boolean }),
actions: Boomed,
}).create({
initialState: () => ({ crashed: false }),
reducer: {
Boomed: (_action, { state }) => [state, dying],
Error: (_action, { draft }) => {
draft.crashed = true;
return draft;
},
},
render: () => null,
});
const withError = await Effect.runPromise(
flaky.run([Boomed.make()], { props: {}, hooks: {}, layer: Layer.empty }),
);
console.log(withError.state);
// => { crashed: true }
console.log(withError.defects.map(({ from, handled }) => ({ from, handled })));
// => [{ from: "Boomed", handled: true }]
console.log(String(withError.defects[0]?.error));
// => "Error: kaboom"The Error handler folds before run resolves, on the same rule the store
uses: state shows the recovery, and defects shows the death that caused
it. handled is true because the feature declared an Error handler and
the death was not inside that handler's own command.
Children
Children: Schema.declare<ReactNode> & { readonly as: <T>() => Schema.declare<T> }Children is a props field that accepts any value. Declared plainly, the key
is required, and JSX that passes no children omits the key. Schema.optionalKey
is the optional form, and Children.as<T>() fixes another type.
const Panel = define({
props: Schema.Struct({
title: Schema.String,
children: Children,
footer: Schema.optionalKey(Children),
row: Children.as<(id: string) => React.ReactNode>(),
}),
state: Schema.Struct({ open: Schema.Boolean }),
actions: Action("Toggled"),
});
const panel = Panel.create({
initialState: Panel.initialState(() => ({ open: true })),
reducer: Panel.reducer({ Toggled: (_payload, { state }) => ({ open: !state.open }) }),
render: Panel.render(({ props, state }) => (
<section>
{props.title}
{state.open ? props.children : null}
{props.row("row_1")}
{props.footer}
</section>
)),
});Children compares equal to any value, so a fresh node from a parent render
never raises PropsChanged. A reducer's snapshot.props.children can
therefore be stale. render always has the current node. Devtools replace an
opaque prop with "<children>". See
Children and opaque props.