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: noopDevtools

A 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): DevtoolsSink

consoleDevtoolsLayer(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): boolean

skipUnchangedAmbient 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));
// => false

createRecorder

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);
// => 0

Events 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 by stop(), before the Unmounted transition.
  • "Completed": the subscription's effect returned.
  • "Died": the subscription's fiber failed; a DevtoolsDefect with from set 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 live Error and a Cause that do not survive JSON.stringify. Only its tag and from (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.

Edit this page on GitHub