Install devtools
Devtools are a service. Merge a sink into the root layer and every feature under that runtime reports its transitions, commands, outputs and defects.
Install the console sink
consoleDevtoolsLayer() is Layer<never>, so both branches of a DEV ternary have one type and the root layer's own requirements do not move.
import { consoleDevtoolsLayer, createRuntime } from "@wych/react";
import { Context, Effect, Layer } from "effect";
class Api extends Context.Service<Api, { readonly load: Effect.Effect<string> }>()("Api") {}
const app = Layer.succeed(Api)({ load: Effect.succeed("ok") });
const devtools = import.meta.env.DEV ? consoleDevtoolsLayer() : Layer.empty;
const { component } = createRuntime(Layer.mergeAll(app, devtools));Name your components. The name is in every event, and component(feature) with no name reports "WychFeature".
import { Action, define } from "@wych/react";
import { Schema } from "effect";
const Bumped = Action("Bumped", {});
const counter = define({
props: Schema.Struct({}),
state: Schema.Struct({ count: Schema.Number }),
action: Action.of([Bumped]),
}).create({
initialState: () => ({ count: 0 }),
reducer: { Bumped: (_payload, { state }) => ({ count: state.count + 1 }) },
render: ({ state, dispatch }) => (
<button onClick={() => dispatch(Bumped.make({}))}>{state.count}</button>
),
});
const Counter = component(counter, { name: "Counter" });Set the console options
createConsoleDevtools takes the options; devtoolsLayer installs the sink it returns.
import { createConsoleDevtools, devtoolsLayer } from "@wych/react";
const verbose = devtoolsLayer(
createConsoleDevtools({
collapsed: false,
diff: true,
timestamps: false,
}),
);collapsed(defaulttrue): usegroupCollapsedfor each event group.predicate(defaultskipUnchangedAmbient): keep the event?diff(defaultfalse): print a shallow own-keys diff of the two states.timestamps(defaulttrue): wall-clock stamp and elapsed since the last event.colors: CSS for the%cdirectives, each field optional.console(defaultglobalThis.console): the four methods the logger calls.
consoleDevtoolsLayer(options) is the same pair in one call.
Swap the predicate
The default drops PropsChanged and HookChanged transitions where previous === next. skipUnchanged drops any transition that did not move state.
import { skipUnchanged } from "@wych/react";
const quiet = devtoolsLayer(createConsoleDevtools({ predicate: skipUnchanged }));
const onlyCart = devtoolsLayer(
createConsoleDevtools({ predicate: (event) => event.name === "Cart" }),
);skipUnchanged also eats two events worth seeing: Unmounted, whose returned state is discarded by design, and a dispatch that deliberately no-ops.
Forward events somewhere else
A sink is one synchronous method. Every field of an event is encodable, so a postMessage transport needs no serialiser.
import type { DevtoolsEvent } from "@wych/react";
const bridge = devtoolsLayer({
onEvent: (event: DevtoolsEvent) => {
window.postMessage({ source: "wych", event }, "*");
},
});
const { component: instrumented } = createRuntime(Layer.mergeAll(app, bridge));onEvent is called at the emission point, so a slow sink slows the fold. Buffer inside your sink if the transport is expensive.
What one event looks like
A Transition for a dispatched Bumped, as JSON:
{
"_tag": "Transition",
"name": "Counter",
"instance": "3",
"cause": { "_tag": "Dispatch" },
"action": { "_tag": "Bumped" },
"previous": { "count": 0 },
"next": { "count": 1 }
}previous and next are the real state references. A sink that keeps them past the call copies them itself.
There is no timestamp on the event. The sink is called synchronously, so a receiver that wants a clock reads its own. The four event shapes and every cause are in the devtools reference.