Devtools
Devtools are a service. The runtime resolves a DevtoolsSink from the root
layer and reports every transition, command, output and defect to it. With no
sink installed the runtime builds no events.
Every snippet on this page builds on one feature: a counter that saves through a command and announces when it passes a threshold.
import { Effect, Layer, Schema } from "effect";
import {
Action,
Command,
consoleDevtoolsLayer,
createConsoleDevtools,
createRecorder,
createRuntime,
define,
devtoolsLayer,
skipUnchanged,
skipUnchangedAmbient,
} from "@wych/react";
import type {
CommandSummary,
DefectSummary,
DevtoolsCause,
DevtoolsCommand,
DevtoolsDefect,
DevtoolsEvent,
DevtoolsOutput,
DevtoolsSink,
DevtoolsSubscriptionStarted,
DevtoolsSubscriptionStopped,
DevtoolsTransition,
} from "@wych/react";
const Bumped = Action("Bumped");
const Reached = Action.output("Reached", { at: Schema.Number });
const Counter = define({
props: Schema.Struct({ step: Schema.Number }),
state: Schema.Struct({ count: Schema.Number }),
actions: Bumped,
outputs: Reached,
});
export const counter = Counter.create({
initialState: Counter.initialState(() => ({ count: 0 })),
reducer: Counter.reducer({
Bumped: (_payload, { state, props }) => [
{ count: state.count + props.step },
(next) => Command.output(Reached, { at: next.count }),
],
}),
render: Counter.render(({ state }) => <b>{state.count}</b>),
});Devtools
Devtools: Context.Reference<DevtoolsSink>; // default: noopDevtoolsA Context.Reference with a default value, so reading it cannot fail and
installing a sink adds no service requirement. RootR and every component
call stay as they are.
DevtoolsSink
interface DevtoolsSink {
readonly onEvent: (event: DevtoolsEvent) => void;
}One method, called synchronously at the emission point. A sink that throws receives no further events from that component.
const tags: Array<string> = [];
const sink: DevtoolsSink = {
onEvent: (event) => {
tags.push(event._tag);
},
};noopDevtools
noopDevtools: DevtoolsSink;The default value of the reference. The runtime compares the resolved sink against this object by identity, and reports nothing when they are the same.
devtoolsLayer
devtoolsLayer(sink: DevtoolsSink): Layer.Layer<never>Layer<never> with no error channel, so merging it changes no type.
const AppLayer = Layer.empty;
const { component } = createRuntime(
Layer.mergeAll(AppLayer, import.meta.env.DEV ? devtoolsLayer(sink) : Layer.empty),
);
const CounterView = component(counter, { name: "Counter" });consoleDevtoolsLayer and createConsoleDevtools
consoleDevtoolsLayer(options?: ConsoleDevtoolsOptions): Layer.Layer<never>
createConsoleDevtools(options?: ConsoleDevtoolsOptions): DevtoolsSinkconsoleDevtoolsLayer(options) is devtoolsLayer(createConsoleDevtools(options)).
The logger prints one console group per event: previous state, action, next
state and cause.
const devRuntime = createRuntime(
Layer.mergeAll(AppLayer, import.meta.env.DEV ? consoleDevtoolsLayer() : Layer.empty),
);Every option has a default.
const verbose = createConsoleDevtools({
collapsed: false, // default true. false uses console.group
predicate: skipUnchanged, // default skipUnchangedAmbient
diff: true, // default false: shallow own-keys diff of the two states
timestamps: false, // default true: wall clock plus elapsed since the last event
colors: { action: "color: #03A9F4; font-weight: bold" },
console: globalThis.console, // default globalThis.console
});diff is shallow and reads own keys only. colors fields are CSS strings for
the %c directives, all individually overridable: previous, action,
next, command, output, defect and subscription. console takes any
object with group, groupCollapsed, groupEnd, log and error.
A SubscriptionStarted or SubscriptionStopped event prints one line, no
group, with the subscription colour:
▸ Presence#1 ⇉ presence:general started
▸ Presence#1 ⇉ presence:general stopped (undeclared)The reason is lower-cased in the line: undeclared, unmounted, completed
or died.
Predicates
skipUnchangedAmbient(event: DevtoolsEvent): boolean
skipUnchanged(event: DevtoolsEvent): booleanskipUnchangedAmbient is the console default. It drops a PropsChanged or
HookChanged transition where previous === next, and keeps everything else.
skipUnchanged drops any transition where state did not move. It also drops
two cases the default keeps: Unmounted, whose returned state is always
discarded, and a dispatch whose handler returns the same state.
Both predicates pass SubscriptionStarted and SubscriptionStopped through
unfiltered: neither carries a state to compare.
const unchanged = { count: 1 };
const unchangedProps: DevtoolsTransition = {
_tag: "Transition",
name: "Counter",
instance: "1",
cause: { _tag: "Lifecycle" },
action: { _tag: "PropsChanged" },
previous: unchanged,
next: unchanged,
};
console.log(skipUnchangedAmbient(unchangedProps));
// => false
console.log(skipUnchanged(unchangedProps));
// => false
const noopDispatch: DevtoolsTransition = { ...unchangedProps, action: { _tag: "Bumped" } };
console.log(skipUnchangedAmbient(noopDispatch));
// => true
console.log(skipUnchanged(noopDispatch));
// => falsecreateRecorder
createRecorder(): { readonly sink: DevtoolsSink; readonly events: ReadonlyArray<DevtoolsEvent>; readonly clear: () => void }An in-memory sink, for asserting on the event stream in a test. events is the
live array as it grows. Emission is synchronous, so by the time an effect
resolves everything has been recorded. Install recorder.sink through
devtoolsLayer; the recorder object holds the sink and the events.
const recorder = createRecorder();
const observed = createRuntime(Layer.mergeAll(AppLayer, devtoolsLayer(recorder.sink)));
const ObservedCounter = observed.component(counter, { name: "Counter" });
recorder.clear();
console.log(recorder.events.length);
// => 0Events come from a mounted component. Emission lives in the store that
component builds, so feature.run reports nothing: a recorder layer passed to
run records an empty array. See
Install devtools.
DevtoolsEvent
type DevtoolsEvent =
| DevtoolsTransition
| DevtoolsCommand
| DevtoolsOutput
| DevtoolsDefect
| DevtoolsSubscriptionStarted
| DevtoolsSubscriptionStopped;Every event carries name, instance and cause. name comes from
component(feature, { name }). instance
is unique per mount on the page. Ids can have gaps: StrictMode calls the store
initialiser twice and each call takes an id.
A mount whose layer fails to build stays dead until a dispatch rebuilds it.
Unmounting a dead mount still reports an Unmounted transition, with its
teardown Command event carrying dropped: true, there being no scope left
to run it in. A dispatch that rebuilds a dead mount reports a second Mounted
transition under the same instance, immediately preceded by the Defect
event with from: "Mounted" that says the first build died.
An event has no timestamp. The sink is called synchronously at the emission point, so the sink can read its own clock.
Every field is encodable, so a sink can be a postMessage transport or a
replay log.
DevtoolsTransition
A reducer ran, whether or not state moved. previous and next are the real
state references, so a sink that keeps them past the call has to copy them.
const transition: DevtoolsTransition = {
_tag: "Transition",
name: "Counter",
instance: "1",
cause: { _tag: "Dispatch" },
action: { _tag: "Bumped" },
previous: { count: 0 },
next: { count: 2 },
};DevtoolsCommand
A reducer returned a command and the runtime took delivery of it.
const issued: DevtoolsCommand = {
_tag: "Command",
name: "Counter",
instance: "1",
cause: { _tag: "Dispatch" },
group: "Bumped",
command: { _tag: "Effect" },
dropped: false,
};group is the default address of this work: the issuing action's tag, where
unkeyed leaves book. A Batch can book members under several names, and those
names are on the Keyed nodes inside command.
dropped: true means no mount was there to take the work, which happens for a
command dispatched after unmount. false means the work reached a live mount.
It says nothing about whether the work completed.
DevtoolsOutput
An outbound message left the feature. The event carries the whole message,
_tag included. The on<Tag> prop receives the payload with _tag stripped.
const announced: DevtoolsOutput = {
_tag: "Output",
name: "Counter",
instance: "1",
cause: { _tag: "Command", action: "Bumped" },
output: Reached.make({ at: 2 }),
};DevtoolsDefect
A command died, an on<Tag> handler threw, or a subscription died.
const died: DevtoolsDefect = {
_tag: "Defect",
name: "Counter",
instance: "1",
cause: { _tag: "Command", action: "Bumped", key: "save" },
from: "Bumped",
defect: { name: "Error", message: "network down" },
handled: true,
};handled: true means an Error handler took it and a Transition for the
Error action follows, with cause: { _tag: "Defect" }. handled: false
means React's error boundary took it.
DevtoolsSubscriptionStarted
A key entered the declared set and the runtime is about to fork it. Reported at the diff, before the fiber exists.
const started: DevtoolsSubscriptionStarted = {
_tag: "SubscriptionStarted",
name: "Presence",
instance: "1",
cause: { _tag: "Lifecycle" },
key: "presence:general",
};DevtoolsSubscriptionStopped
A running subscription is no longer running, or no longer declared. reason
is one of four:
"Undeclared": a fold no longer declares the key."Unmounted": reported bystop(), before theUnmountedtransition."Completed": the subscription's effect returned."Died": the subscription's fiber failed; aDevtoolsDefectwithfromset to the key precedes it.
const stopped: DevtoolsSubscriptionStopped = {
_tag: "SubscriptionStopped",
name: "Presence",
instance: "1",
cause: { _tag: "Lifecycle" },
key: "presence:general",
reason: "Undeclared",
};See Subscriptions for the diff that produces these events, and Subscribe to a stream for a worked example.
DevtoolsCause
type DevtoolsCause =
| { readonly _tag: "Dispatch" }
| { readonly _tag: "Command"; readonly action: string; readonly key?: string }
| { readonly _tag: "Lifecycle" }
| { readonly _tag: "Defect"; readonly from: string }
| { readonly _tag: "Subscription"; readonly key: string };const causes: ReadonlyArray<DevtoolsCause> = [
{ _tag: "Dispatch" }, // from React: an event handler, or a caller holding the store
{ _tag: "Command", action: "Bumped", key: "save" }, // emitted by a running command
{ _tag: "Lifecycle" }, // Mounted, PropsChanged, HookChanged or Unmounted
{ _tag: "Defect", from: "Bumped" }, // the Error action the runtime folded
{ _tag: "Subscription", key: "presence:general" }, // dispatched by a running subscription, or its death
];key is present when the emitting command was Command.keyed. The name a
Command.cancel would use for that fiber is key ?? action.
There is no Output cause. What a parent does with an output happens outside
the runtime, so no event has that origin.
Summaries
const commandSummary: CommandSummary = {
_tag: "Batch",
commands: [
{ _tag: "Cancel", target: "save" },
{ _tag: "Keyed", key: "save", command: { _tag: "Effect" } },
],
};
const firstSummary: CommandSummary = {
_tag: "Keyed",
key: "Task/Save",
command: { _tag: "Effect" },
first: true,
};
const defectSummary: DefectSummary = { name: "Error", message: "network down", stack: "..." };CommandSummary has the shape of the command with the leaf's callback removed.
A Keyed node from a task declared mode: "first" carries first: true,
and the console logger prints it as keyedFirst(Task/Save, effect).
DefectSummary flattens an unknown thrown value to { name?, message, stack? },
because an Error serialises to {}.
Redaction
Two actions are scrubbed before a sink sees them:
Error, which holds a liveErrorand aCausethat do not surviveJSON.stringify. Only its tag andfrom(a string) reach the sink.HookChanged, which holds a record that routinely holds functions. Only its tag reaches the sink.
const scrubbed = [
{
...transition,
action: { _tag: "Error", from: "Bumped" },
cause: { _tag: "Defect", from: "Bumped" },
},
{ ...transition, action: { _tag: "HookChanged" }, cause: { _tag: "Lifecycle" } },
];PropsChanged keeps its previous props, with each opaque prop replaced by
its placeholder. A Children prop reports as "<children>", so every event
survives a JSON round trip. The reducer's own snapshot keeps the
real node. See
Children and opaque props.
const reported = { _tag: "PropsChanged", previous: { step: 1, children: "<children>" } };
const propsChanged: DevtoolsTransition = {
...transition,
cause: { _tag: "Lifecycle" },
action: reported,
};State reaches a sink verbatim, with no redaction. That is why define throws
when the state schema declares an opaque field.