Recover from a failed layer
A feature layer can fail to build: a socket refuses to connect, a token has
expired. The failure reaches the Error handler with from: "Mounted", the
same channel a dying command uses. Render a Retry button from that handler,
and Wych rebuilds the layer the next time the button is clicked.
import { Context, Effect, Layer, Schema } from "effect";
import { Action, Command, createRuntime, define } from "@wych/react";
class Metrics extends Context.Service<Metrics, { readonly snapshot: Effect.Effect<number> }>()(
"Metrics",
) {}
const Loaded = Action("Loaded", { value: Schema.Number });
const Retry = Action("Retry");
const loadMetrics = Command.effect(Loaded, (dispatch) =>
Effect.gen(function* () {
const metrics = yield* Metrics;
const value = yield* metrics.snapshot;
yield* dispatch(Loaded, { value });
}),
);
const Dashboard = define({
props: Schema.Struct({}),
state: Schema.Struct({ status: Schema.String, attempts: Schema.Number, value: Schema.Number }),
actions: [Loaded, Retry],
});
export const dashboard = Dashboard.create({
initialState: Dashboard.initialState(() => ({ status: "connecting", attempts: 0, value: 0 })),
reducer: Dashboard.reducer({
Mounted: (_payload, { state }) => [state, loadMetrics],
Retry: (_payload, { draft }) => {
draft.status = "connecting";
return [draft, loadMetrics];
},
Loaded: ({ value }, { draft }) => {
draft.status = "loaded";
draft.value = value;
return draft;
},
Error: ({ from }, { draft, state }) => {
const attempts = state.attempts + 1;
draft.status = from === "Mounted" && attempts < 3 ? "failed" : "gaveUp";
draft.attempts = attempts;
return draft;
},
}),
render: Dashboard.render(({ state, dispatch }) => (
<div>
<span data-testid="status">{state.status}</span>
{state.status === "failed" ? <button onClick={() => dispatch(Retry)}>Retry</button> : null}
{state.status === "gaveUp" ? <p>Could not connect. Reload the page.</p> : null}
{state.status === "loaded" ? <p>{state.value}</p> : null}
</div>
)),
});Mounted fires only once the layer built, so it never sees from: "Mounted"
failures itself; those short-circuit straight to Error before Mounted
folds. Retry and Mounted share loadMetrics, so the first load and every
retried load run the same command.
The Error handler above returns bare state for from: "Mounted", never a
command. A layer that failed to build leaves no mount fiber to run a command
in, so a command returned here is dropped (devtools reports it as a Command
event with dropped: true). Only a command a dispatch produces rebuilds the
layer, which is why the button dispatches Retry instead of the handler
returning loadMetrics directly.
Count attempts and give up
The Error handler is a pure fold over from and the running count, so it
takes no mount to prove.
import { Cause } from "effect";
import { Next } from "@wych/react";
const firstFailure = dashboard.reduce(
{
_tag: "Error",
error: new Error("refused"),
cause: Cause.die(new Error("refused")),
from: "Mounted",
},
{ state: { status: "connecting", attempts: 0, value: 0 }, props: {}, hooks: {} },
);
console.log(Next.state(firstFailure));
// => { status: "failed", attempts: 1, value: 0 }
const thirdFailure = dashboard.reduce(
{
_tag: "Error",
error: new Error("refused"),
cause: Cause.die(new Error("refused")),
from: "Mounted",
},
{ state: { status: "failed", attempts: 2, value: 0 }, props: {}, hooks: {} },
);
console.log(Next.state(thirdFailure));
// => { status: "gaveUp", attempts: 3, value: 0 }Branching on from matters here: a defect from Loaded's own command (a bug
in metrics.snapshot, say) would carry from: "Loaded" and fall straight to
"gaveUp" on the first attempt, because that failure is not the layer and a
Retry click would not change its outcome.
Retry rebuilds the layer
Only a dispatch that did not originate from a lifecycle action or a running
command rebuilds a dead mount. A click handler's dispatch(Retry)
qualifies, so the button in the snippet above is the trigger, not loadMetrics
calling dispatch from inside itself, and not Mounted's own command on the
rebuilt mount. That is what stops a permanently failing layer from spinning:
each Retry click rebuilds the layer exactly once, and a build that fails again
reaches Error with from: "Mounted" a second time rather than looping on
its own.
Unmounting a dead mount still folds Unmounted, so a cleanup command written
there still runs. See Error for the full
set of origins from reports, and
assert on a dying command
for testing the reducer half of a defect without a mount.