Lifecycle
The runtime raises five actions. Their tags are reserved, so a feature cannot declare an action with one of these names. Every handler is optional, and an unhandled lifecycle action leaves state unchanged.
Every snippet on this page builds on one feature: a presence indicator that subscribes to a room and reports who is online.
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect";
import { Action, Command, define, Next } from "@wych/react";
import type { LifecycleAction } from "@wych/react";
class Presence extends Context.Service<
Presence,
{ readonly watch: (roomId: string) => Stream.Stream<ReadonlyArray<string>> }
>()("Presence") {}
const PresenceLayer = Layer.succeed(Presence)({
watch: () => Stream.make(["ada"], ["ada", "grace"]),
});
const Arrived = Action("Arrived", { members: Schema.Array(Schema.String) });
const watch = (roomId: string) =>
Command.restart(
"watch",
Command.effect<{ readonly _tag: "Arrived"; readonly members: ReadonlyArray<string> }, Presence>(
(dispatch) =>
Effect.flatMap(Presence, (presence) =>
Stream.runForEach(presence.watch(roomId), (members) =>
dispatch({ _tag: "Arrived", members }),
),
),
),
);
const Room = define({
props: Schema.Struct({ roomId: Schema.String }),
state: Schema.Struct({ members: Schema.Array(Schema.String), failed: Schema.String }),
action: Action.of([Arrived]),
useUnsafeHooks: (props) => ({ channel: `room:${props.roomId}` }),
});The five actions
type LifecycleAction<Props, H> =
| { readonly _tag: "Mounted" }
| { readonly _tag: "PropsChanged"; readonly previous: Props }
| { readonly _tag: "HookChanged"; readonly previous: H }
| { readonly _tag: "Error"; readonly error: unknown; readonly cause: Cause.Cause<never> }
| { readonly _tag: "Unmounted" };A handler receives the payload with _tag stripped, on the same terms as an
action handler. Mounted and Unmounted therefore receive {}.
const room = Room.create({
initialState: Room.initialState(() => ({ members: [], failed: "" })),
reducer: Room.reducer({
Arrived: ({ members }, { state }) => ({ ...state, members }),
Mounted: (_payload, { state, props }) => [state, watch(props.roomId)],
PropsChanged: ({ previous }, { state, props }) =>
previous.roomId === props.roomId ? state : [{ ...state, members: [] }, watch(props.roomId)],
HookChanged: ({ previous }, { state }) => {
console.log(previous.channel);
return state;
},
Error: ({ error, cause }, { state }) => ({
...state,
failed: Cause.hasDies(cause) ? "bug" : String(error),
}),
Unmounted: (_payload, { state }) => [state, Command.cancel("watch")],
}),
render: Room.render(({ state }) => (
<ul>
{state.members.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
)),
});Order
Mounted, once per mount, raised from an effect after the commit.PropsChangedandHookChanged, whenever their values change.Unmounted, at teardown.
Mounted fires once per effect cycle, so it fires twice under React
StrictMode in development. Write the handler to be idempotent.
const mounted = room.reduce(
{ _tag: "Mounted" },
{
state: { members: [], failed: "" },
props: { roomId: "r_1" },
hooks: { channel: "room:r_1" },
},
);
console.log(Next.state(mounted));
// => { members: [], failed: "" }
console.log(Next.command(mounted) !== undefined);
// => trueOne window is uncovered: a props change landing between the first render and
the mount effect buffers its command ahead of Mounted's.
PropsChanged
Props are compared by value with Schema.toEquivalence over the props schema.
An unchanged parent re-render folds nothing, and returning the same state
reference is the no-op.
const sameRoom = room.reduce(
{ _tag: "PropsChanged", previous: { roomId: "r_1" } },
{
state: { members: ["ada"], failed: "" },
props: { roomId: "r_1" },
hooks: { channel: "room:r_1" },
},
);
console.log(Next.command(sameRoom));
// => undefined
const newRoom = room.reduce(
{ _tag: "PropsChanged", previous: { roomId: "r_1" } },
{
state: { members: ["ada"], failed: "" },
props: { roomId: "r_2" },
hooks: { channel: "room:r_2" },
},
);
console.log(Next.state(newRoom));
// => { members: [], failed: "" }A Children prop is opaque and its equivalence is constantly true, so a fresh
node never raises PropsChanged. The reducer's snapshot.props.children can
be stale. render always has the current node.
The reported previous in a devtools event has each opaque prop replaced by
"<children>". The reducer's own snapshot keeps the real node.
HookChanged and useUnsafeHooks
useUnsafeHooks: (props, state) => H is called in render position on every
render. Its result arrives as snapshot.hooks. Hooks are compared per key with
Object.is, and a change in any key raises HookChanged { previous }.
const hookChanged = room.reduce(
{ _tag: "HookChanged", previous: { channel: "room:r_1" } },
{
state: { members: ["ada"], failed: "" },
props: { roomId: "r_2" },
hooks: { channel: "room:r_2" },
},
);
console.log(Next.state(hookChanged));
// => { members: ["ada"], failed: "" }A hook returning a fresh object or a fresh function on every render raises
HookChanged on every render. Memoize inside the hook, or return primitives.
useUnsafeHooks sees the committed state read before the fold of
PropsChanged and HookChanged, so a hook value derived from state can lag
until the next dispatch or ambient change.
Error
Error: (payload: { readonly error: unknown; readonly cause: Cause.Cause<never> }, snapshot) => Next;Two things reach this handler as defects: a command that dies, and a feature
layer that fails to build. error is the squashed cause. cause is the real
one, for a handler that wants to tell a defect from a typed failure.
const failed = room.reduce(
{
_tag: "Error",
error: new Error("socket closed"),
cause: Cause.die(new Error("socket closed")),
},
{
state: { members: [], failed: "" },
props: { roomId: "r_1" },
hooks: { channel: "room:r_1" },
},
);
console.log(Next.state(failed));
// => { members: [], failed: "bug" }With no Error handler declared, the defect is rethrown during render and
reaches the nearest React error boundary. Interruption is never reported as a
defect: Command.cancel and unmount end fibers on purpose.
A missing on<Tag> prop for an output also throws to the boundary, and it does
not reach this handler.
Unmounted
The component is gone, so the runtime reads Next.command(next) and discards
the rest. Return snapshot.state and put the work in the command.
const torn = room.reduce(
{ _tag: "Unmounted" },
{
state: { members: ["ada"], failed: "" },
props: { roomId: "r_1" },
hooks: { channel: "room:r_1" },
},
);
console.log(Next.state(torn));
// => { members: ["ada"], failed: "" }
console.log(Next.command(torn)?._tag);
// => "Cancel"reduce discards the returned state the same way the runtime does, so a
teardown test cannot disagree with the mount.
Two ordering facts about teardown:
- In-flight work is interrupted before the
Unmountedcommand is interpreted. A flush-on-exit belongs in theUnmountedhandler. - The
Unmountedcommand runs with the feature's services still alive, then the mount scope closes. Teardown is bounded at 5 seconds; an abandoned teardown is reported as a defect.
The teardown command is unkeyed, so it books under the group "Unmounted".
Server rendering
Nothing folds under renderToString: no Mounted, no commands. Effects do not
run on the server, and the store's arming lives in an effect. See
Render on the server.