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 actions = Action({
  Picked: { name: Schema.String },
  Progressed: { percent: Schema.Number },
  Finished: {},
  Failed: { message: Schema.String },
  Cancelled: {},
});

const Uploader = define({
  props: Schema.Struct({}),
  state: Schema.Struct({
    name: Schema.String,
    percent: Schema.Number,
    status: Schema.String,
  }),
  actions,
});

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 }, { draft }) => {
      draft.name = name;
      draft.percent = 0;
      draft.status = "uploading";
      return [
        draft,
        Command.restart(
          "upload",
          Command.effect((dispatch) =>
            Effect.gen(function* () {
              const uploads = yield* Uploads;
              yield* Stream.runForEach(uploads.upload(name), (percent) =>
                dispatch(actions.Progressed, { percent }),
              );
              yield* dispatch(actions.Finished);
            }).pipe(
              Effect.catchCause((cause) =>
                dispatch(actions.Failed, { message: String(Cause.squash(cause)) }),
              ),
            ),
          ),
        ),
      ];
    },
    Progressed: ({ percent }, { draft }) => {
      draft.percent = percent;
      return draft;
    },
    Finished: (_payload, { draft }) => {
      draft.percent = 100;
      draft.status = "done";
      return draft;
    },
    Failed: ({ message }, { draft }) => {
      draft.status = message;
      return draft;
    },
    Cancelled: (_payload, { draft }) => {
      draft.status = "cancelled";
      return [draft, Command.cancel("upload")];
    },
  },
  render: () => null,
});

The command the handler returned is readable before anything runs.

const picked = uploader.reduce(actions.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([actions.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.

import { Effect as E } from "effect";

const loopback = Command.effect(actions.Progressed, (dispatch) =>
  Stream.runForEach(Stream.make(10, 20), (percent) => dispatch(actions.Progressed, { 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 the messages it may emit as its first argument, as loopback does. Either way dispatch takes the message and its payload, and builds the value itself.

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(actions.Progressed, (dispatch) =>
    Effect.gen(function* () {
      const uploads = yield* Uploads;
      yield* Stream.runForEach(
        uploads
          .upload(name)
          .pipe(Stream.throttle({ cost: () => 1, units: 1, duration: "100 millis" })),
        (percent) => dispatch(actions.Progressed, { percent }),
      );
    }).pipe(Effect.catchCause(() => E.void)),
  );

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([actions.Picked.make({ name: "photo.jpg" }), actions.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. Next.lazy(state, thunk) is that [state, thunk] tuple with a name, and it types the thunk's argument as the state the handler just built.

const lazily = Uploader.reducer({
  Picked: ({ name }, { draft }) => {
    draft.name = name;
    draft.percent = 0;
    draft.status = "uploading";
    return Next.lazy(draft, (next) =>
      Command.effect((dispatch) =>
        Effect.gen(function* () {
          const uploads = yield* Uploads;
          yield* Stream.runForEach(uploads.upload(next.name), (percent) =>
            dispatch(actions.Progressed, { percent }),
          );
        }).pipe(Effect.catchCause(() => E.void)),
      ),
    );
  },
  Progressed: ({ percent }, { draft }) => {
    draft.percent = percent;
    return draft;
  },
  Finished: (_payload, { draft }) => {
    draft.status = "done";
    return draft;
  },
  Failed: ({ message }, { draft }) => {
    draft.status = message;
    return draft;
  },
  Cancelled: (_payload, { draft }) => {
    draft.status = "cancelled";
    return [draft, 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.lazy({ 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([actions.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 and under run, both reach the Error lifecycle handler. A mount with no handler rethrows the defect during render, which is the only place a React error boundary can catch it; run has no render to rethrow into, so it stays total and records the defect instead. Interruption is how commands normally end, so a cancelled or unmounted fiber is never reported as a defect.

One limit

run counts in-flight command work to decide when it is finished, so a command that never completes keeps it from resolving. A long-lived source belongs in a subscription instead, where it is declared rather than issued; run resolves at command quiescence regardless of which subscriptions are still running. See subscriptions for the hook and the model for why the two are kept apart. Test a command that is meant to be cancelled by cancelling it, as stopped does above, or fold it through reduce and read the command.

A command that dies is a defect, not this limit: run routes it to the Error handler on the store's own rule and records it in defects, so "given a failing command, this feature recovers" is a state assertion and "this command failed" is a defects assertion. See test a feature without React for the recipe. Constructor signatures are in commands.

Edit this page on GitHub