Commands as data

A file upload in React is an async function inside an event handler. It awaits, it calls setProgress in a loop, and it hopes the component is still there when it returns. Cancelling it means an AbortController in a ref. Testing it means a mounted component and a mocked network.

In Wych a handler returns the upload as a value. Nothing in the handler runs.

import { Action, Command, define, Next } from "@wych/react";
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect";

class Uploads extends Context.Service<
  Uploads,
  { readonly upload: (name: string) => Stream.Stream<number, Error> }
>()("Uploads") {}

const Picked = Action("Picked", { name: Schema.String });
const Progressed = Action("Progressed", { percent: Schema.Number });
const Finished = Action("Finished", {});
const Failed = Action("Failed", { message: Schema.String });
const Cancelled = Action("Cancelled", {});

const Uploader = define({
  props: Schema.Struct({}),
  state: Schema.Struct({
    name: Schema.String,
    percent: Schema.Number,
    status: Schema.String,
  }),
  action: Action.of([Picked, Progressed, Finished, Failed, Cancelled]),
});

The reducer stays a pure function of (payload, snapshot). That is what makes feature.reduce callable in a test, feature.run able to fold a sequence, and a devtools transition a pair of plain values.

const uploader = Uploader.create({
  initialState: () => ({ name: "", percent: 0, status: "idle" }),
  reducer: {
    Picked: ({ name }, { state }) => [
      { ...state, name, percent: 0, status: "uploading" },
      Command.restart(
        "upload",
        Command.effect((dispatch) =>
          Effect.flatMap(Uploads, (uploads) =>
            Stream.runForEach(uploads.upload(name), (percent) =>
              dispatch(Progressed.make({ percent })),
            ),
          ).pipe(
            Effect.flatMap(() => dispatch(Finished.make({}))),
            Effect.catchCause((cause) =>
              dispatch(Failed.make({ message: String(Cause.squash(cause)) })),
            ),
          ),
        ),
      ),
    ],
    Progressed: ({ percent }, { state }) => ({ ...state, percent }),
    Finished: (_payload, { state }) => ({ ...state, percent: 100, status: "done" }),
    Failed: ({ message }, { state }) => ({ ...state, status: message }),
    Cancelled: (_payload, { state }) => [
      { ...state, status: "cancelled" },
      Command.cancel("upload"),
    ],
  },
  render: () => null,
});

The command the handler returned is readable before anything runs.

const picked = uploader.reduce(Picked.make({ name: "photo.jpg" }), {
  state: { name: "", percent: 0, status: "idle" },
  props: {},
  hooks: {},
});

console.log(Next.state(picked));
// => { name: "photo.jpg", percent: 0, status: "uploading" }
console.log(Next.command(picked)?._tag);
// => "Batch"

Who interprets it decides what happens. run forks it against the layer it was given. The React store forks it into the mount's scope and books its fiber. A test can read it and assert on the shape without running anything, which the async handler could never offer.

const threeSteps = Layer.succeed(Uploads)({
  upload: () => Stream.make(25, 50, 100),
});

const done = await Effect.runPromise(
  uploader.run([Picked.make({ name: "photo.jpg" })], {
    props: {},
    hooks: {},
    layer: threeSteps,
  }),
);

console.log(done.emitted.map((action) => action._tag));
// => ["Progressed", "Progressed", "Progressed", "Finished"]
console.log(done.state);
// => { name: "photo.jpg", percent: 100, status: "done" }

One leaf

Command.effect is the only leaf. It takes (dispatch) => Effect<unknown, never, R> and emits by calling dispatch: zero times, once, or on every element of a stream. The other constructors combine, name or interrupt.

An earlier version had Command.stream as a second leaf. It described one Effect shape as its own ADT node, and every Stream combinator was already available one call earlier, inside the effect. It was removed, along with Command.ignore, Command.queue and a Policy type, for the reason below.

import { Effect as E } from "effect";

const loopback = Command.effect<typeof Progressed.Type>((dispatch) =>
  Stream.runForEach(Stream.make(10, 20), (percent) => dispatch(Progressed.make({ percent }))),
);

console.log(loopback._tag);
// => "Effect"

Inside a handler's return, dispatch is typed from the contextual return type of the reducer. A command written standalone has no such context, so it names its own vocabulary, as loopback does.

Concurrency belongs to Effect

Debounce, throttle, retry and take-latest are Effect combinators. A throttled progress report is Stream.throttle where the work is written.

const throttled = (name: string) =>
  Command.effect<typeof Progressed.Type, Uploads>((dispatch) =>
    Effect.flatMap(Uploads, (uploads) =>
      Stream.runForEach(
        uploads
          .upload(name)
          .pipe(Stream.throttle({ cost: () => 1, units: 1, duration: "100 millis" })),
        (percent) => dispatch(Progressed.make({ percent })),
      ),
    ).pipe(Effect.catchCause(() => E.void)),
  );

A policy vocabulary written as data could only be a smaller copy of that. The runtime owns the one thing a handler cannot write for itself: naming a running fiber so a different action's handler can interrupt it. That is Command.keyed and Command.cancel, and it is the whole supervisor. The Cancelled handler above reaches work Picked started, by name. See groups and cancellation.

const stopped = await Effect.runPromise(
  uploader.run([Picked.make({ name: "photo.jpg" }), Cancelled.make({})], {
    props: {},
    hooks: {},
    layer: Layer.succeed(Uploads)({
      upload: () => Stream.fromEffect(Effect.sleep("50 millis").pipe(Effect.as(100))),
    }),
  }),
);

console.log(stopped.emitted);
// => []
console.log(stopped.state.status);
// => "cancelled"

A command can be lazy

A handler that writes state inline often needs that same state in the command. The lazy form hands the thunk the state it sits beside, so the handler keeps its one-expression body.

const lazily = Uploader.reducer({
  Picked: ({ name }, { state }) => [
    { ...state, name, percent: 0, status: "uploading" },
    (next) =>
      Command.effect((dispatch) =>
        Effect.flatMap(Uploads, (uploads) =>
          Stream.runForEach(uploads.upload(next.name), (percent) =>
            dispatch(Progressed.make({ percent })),
          ),
        ).pipe(Effect.catchCause(() => E.void)),
      ),
  ],
  Progressed: ({ percent }, { state }) => ({ ...state, percent }),
  Finished: (_payload, { state }) => ({ ...state, status: "done" }),
  Failed: ({ message }, { state }) => ({ ...state, status: message }),
  Cancelled: (_payload, { state }) => [{ ...state, status: "cancelled" }, Command.cancel("upload")],
});

Next.command is the single resolution point. It calls the thunk once, with the tuple's own state, and returns what it returned. reduce's Unmounted branch, run, the store's fold and its teardown all read through that accessor, so a lazy command reaches the interpreter already resolved. A Lazy ADT variant would put a second resolution site in every consumer, and devtools would report a function where a command belongs.

const lazyNext: Next<{ name: string }, never> = [{ name: "a.jpg" }, () => Command.none];
console.log(Next.command(lazyNext)?._tag);
// => "None"

Commands cannot fail

The leaf's error channel is never. An effect with an open error channel does not compile.

// @ts-expect-error a command's error channel is `never`
const failing = Command.effect(() => Effect.fail("boom"));

This is deliberate. A failure the feature cares about is part of its model: the user sees it, so it belongs in an action payload or a task field. Effect.catchCause inside the leaf turns the failure into a dispatch, which is what the Picked handler does with Failed. The React version put the same fact in a catch block that called setError, where no test could reach it without a render.

const broken = Layer.succeed(Uploads)({
  upload: () => Stream.fail(new Error("disk full")),
});

const failed = await Effect.runPromise(
  uploader.run([Picked.make({ name: "photo.jpg" })], { props: {}, hooks: {}, layer: broken }),
);

console.log(failed.state.status);
// => "Error: disk full"

What remains is a defect: a bug in the effect, or a feature layer that fails to build. Under a mount both reach the Error lifecycle handler. With no handler the defect is rethrown during render, which is the only place a React error boundary can catch it. Interruption is how commands normally end, so a cancelled or unmounted fiber is never reported as a defect.

Two limits

run counts in-flight work to decide when it is finished, so a command that never completes keeps it from resolving. Test a subscription by cancelling it, as stopped does above, or fold it through reduce and read the command.

run also discards a command that dies. The store routes a dying command to the Error handler; run does not, so a test of "given a failing command, this feature recovers" passes without checking anything. That is an open item in the runtime, and until it closes the honest test for a defect is a mounted component with a recorder. Constructor signatures are in commands.

Edit this page on GitHub