Runtime

createRuntime builds the root of a Wych application. It owns one ManagedRuntime over the root Layer, and it turns a Feature into a React component.

Every snippet on this page builds on one feature: a cart that places an order through a Checkout service and announces the order id to its parent.

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

class Checkout extends Context.Service<
  Checkout,
  { readonly place: (customerId: string) => Effect.Effect<string> }
>()("Checkout") {}

const CheckoutLayer = Layer.succeed(Checkout)({
  place: (customerId) => Effect.succeed(`order_for_${customerId}`),
});

const actions = Action({
  Added: { sku: Schema.String },
  Ordered: { orderId: Schema.String },
  Emptied: {},
});
const OrderPlaced = Action.output("OrderPlaced", { orderId: Schema.String });

const Cart = define({
  props: Schema.Struct({ customerId: Schema.String }),
  state: Schema.Struct({ items: Schema.Array(Schema.String) }),
  actions,
  outputs: OrderPlaced,
});

export const cart = Cart.create({
  initialState: Cart.initialState(() => ({ items: [] })),
  reducer: Cart.reducer({
    Added: ({ sku }, { draft, props }) => {
      draft.items.push(sku);
      return [
        draft,
        Command.effect((dispatch) =>
          Effect.gen(function* () {
            const checkout = yield* Checkout;
            const orderId = yield* checkout.place(props.customerId);
            yield* dispatch(actions.Ordered, { orderId });
          }),
        ),
      ];
    },
    Ordered: ({ orderId }, { state }) => [state, Command.output(OrderPlaced, { orderId })],
    Emptied: (_payload, { draft }) => {
      draft.items = [];
      return draft;
    },
  }),
  render: Cart.render(({ state, dispatch }) => (
    <button onClick={() => dispatch(actions.Added, { sku: "sku_1" })}>
      Add ({state.items.length})
    </button>
  )),
});

createRuntime

createRuntime<RootR, RootE>(
  layer: Layer.Layer<RootR, RootE>,
): { Provider; component; useRuntime }

One argument: the root Layer. Pass Layer.empty when the application has no services.

const { Provider, component, useRuntime } = createRuntime(CheckoutLayer);

The runtime is created once, at module scope. Its ManagedRuntime is built on the first mount and lives for the life of the module.

Provider

Provider: FC<{ readonly children?: ReactNode }>;

Provider puts the runtime into React context.

const CartView = component(cart, { name: "Cart" });

export const App = () => (
  <Provider>
    <CartView customerId="c_1" onOrderPlaced={({ orderId }) => console.log(orderId)} />
  </Provider>
);

Provider is optional. A component resolves the runtime it was created from, so the tree above renders identically without it.

component

component(feature, options: { readonly name: string }): FeatureComponent
component(feature, options: { readonly layer: Layer; readonly name: string }): FeatureComponent

The first overload takes a feature whose services R are all in the root layer. The second takes a feature that needs more, and a layer that supplies the services the root does not: Exclude<R, RootR>.

class Analytics extends Context.Service<
  Analytics,
  { readonly track: (event: string) => Effect.Effect<void> }
>()("Analytics") {}

const AnalyticsLayer = Layer.succeed(Analytics)({ track: () => Effect.void });

const tracked = Cart.create({
  initialState: Cart.initialState(() => ({ items: [] })),
  reducer: Cart.reducer({
    Added: ({ sku }, { draft }) => {
      draft.items.push(sku);
      return [
        draft,
        Command.effect(() =>
          Effect.gen(function* () {
            const analytics = yield* Analytics;
            yield* analytics.track(sku);
          }),
        ),
      ];
    },
    Ordered: ({ orderId }, { state }) => [state, Command.output(OrderPlaced, { orderId })],
    Emptied: (_payload, { draft }) => {
      draft.items = [];
      return draft;
    },
  }),
  render: Cart.render(() => null),
});

const TrackedCart = component(tracked, { layer: AnalyticsLayer, name: "TrackedCart" });

A service that neither the root nor the feature layer provides is a compile error at component, before anything mounts.

// @ts-expect-error Analytics is not provided by the root layer
const Unprovided = component(tracked, { name: "Tracked" });

The feature layer is built once per mount and released when that mount closes. A service that must outlive a mount belongs in the root layer. A layer that fails to build rebuilds on the next dispatch rather than staying dead for the life of the mount; see Recover from a failed layer.

name

name is required. It is the component's displayName, the name field of every devtools event, and the scope of useFeature: a fragment finds the nearest mount of a component with the same name. Two component() calls with one name share that scope, and two names never do. The scope survives a Fast Refresh of the file that called component().

console.log(CartView.displayName);
// => "Cart"

Output props

Every declared output becomes a required on<Tag> prop. The payload arrives with _tag stripped; the prop name already carries the tag.

type OutputProps<Output extends { readonly _tag: string }> = {
  readonly [K in Output["_tag"] as `on${K}`]: (payload: Omit<..., "_tag">) => void;
};
const parent = <CartView customerId="c_1" onOrderPlaced={({ orderId }) => console.log(orderId)} />;

// @ts-expect-error Property 'onOrderPlaced' is missing
const missing = <CartView customerId="c_1" />;

A feature that declares no outputs gets {}, so the prop set is its props schema alone.

An output that leaves while its on<Tag> prop is absent throws to the nearest React error boundary. JSX cannot omit the prop, because the prop is required.

// throws TypeError: No "onOrderPlaced" prop for output "OrderPlaced"

Outputs never re-enter the reducer. See Actions and outputs.

Props validation

Props are validated against the props schema with onExcessProperty: "error" and errors: "all". Validation runs on mount and on every props identity change, and it does not run for a state-driven re-render.

import { renderToString } from "react-dom/server";

renderToString(<CartView customerId={1 as unknown as string} onOrderPlaced={() => {}} />);
// throws TypeError: Invalid props for <Cart>:

The TypeError reaches the nearest React error boundary. Its message lists every problem with its path. Validation only checks the props. define normalizes the props schema to its Type side, so a transforming field is not decoded again on a parent render.

State validation

In development, the initial state and every new next state are validated against the state schema with onExcessProperty: "error" and errors: "all". The check also covers the task slot's fields, each against its Task.schema. Every key and value is checked, at every depth. That catches an extra key in a spread, a nested spread, a draft a cast wrote into, and a wrong value.

Development is process.env.NODE_ENV !== "production", read when define runs. A bundler replaces the expression, and Vitest sets it to "test". Without a bundler and without a process global, the expression reads as production, so nothing is checked.

const loose = Cart.create({
  initialState: Cart.initialState(() => ({ items: [] })),
  reducer: Cart.reducer({
    Added: ({ sku }, { state }) => ({ ...state, items: [...state.items, sku], total: 1 }),
    Ordered: ({ orderId }, { state }) => [state, Command.output(OrderPlaced, { orderId })],
    Emptied: (_payload, { state }) => state,
  }),
  render: () => null,
});

loose.reduce(actions.Added.make({ sku: "sku_1" }), {
  state: { items: [] },
  props: { customerId: "c_1" },
  hooks: {},
});
// throws TypeError: handler returned a state that does not match the state schema:
// Expected no excess property
//   at ["total"]

The TypeError lists every issue with its path. Under the store it is a defect of that action and the state is kept; under feature.run the Effect rejects. initialState is checked where the initial state is built, with the prefix initialState returned a state that does not match the state schema:. A handler that returns state itself is not checked again: every state the store holds passed when it was built. In production nothing is checked.

FeatureComponent.useFeature

CartView.useFeature(): RenderSnapshot<Props, State, Action | Output, H>

useFeature returns the same { state, props, hooks, dispatch } object that render received on that render. Use it for a view fragment that is part of the feature's view and lives in its own file.

const ItemCount = () => {
  const { state, dispatch } = CartView.useFeature();
  return (
    <button onClick={() => dispatch(actions.Added, { sku: "sku_2" })}>
      {state.items.length} items
    </button>
  );
};

dispatch is reference-stable for the life of the mount. Called outside a mount of a component with that name, useFeature throws.

renderToString(<ItemCount />);
// throws TypeError: Cart.useFeature() called outside <Cart>

Do not call useFeature inside render; render already has the snapshot as its argument.

dispatch

interface Dispatch<Action> {
  <M extends MessageOf<Action>>(message: M, ...payload: PayloadArgs<M>): void;
  (action: Action): void;
}

The dispatch that render and useFeature receive takes a message schema and its payload, or a built message. The payload argument is optional when every field of the message is optional. dispatch accepts the declared actions and the declared outputs; an output dispatched from the view leaves through its on<Tag> prop.

const Controls = () => {
  const { dispatch } = CartView.useFeature();
  return (
    <>
      <button onClick={() => dispatch(actions.Added, { sku: "sku_3" })}>Add</button>
      <button onClick={() => dispatch(actions.Emptied)}>Empty</button>
      <button onClick={() => dispatch(actions.Added.make({ sku: "sku_4" }))}>Add again</button>
      <button onClick={() => dispatch(OrderPlaced, { orderId: "o_1" })}>Ship</button>
    </>
  );
};

An undeclared tag, a missing required payload and a wrong payload are compile errors.

const Wrong = () => {
  const { dispatch } = CartView.useFeature();
  // @ts-expect-error Added has a required field
  dispatch(actions.Added);
  // @ts-expect-error sku is a string
  dispatch(actions.Added, { sku: 1 });
  return null;
};

make validates the payload, so a payload the schema rejects at runtime throws out of the event handler that called dispatch. The Dispatcher a command's effect receives has the same two forms; see Commands.

useRuntime

useRuntime(): ManagedRuntime.ManagedRuntime<RootR, RootE>

The root ManagedRuntime, for a plain React component outside any feature.

const placeOrder = Effect.gen(function* () {
  const checkout = yield* Checkout;
  yield* checkout.place("c_1");
});

const PlaceOrderButton = () => {
  const runtime = useRuntime();
  return <button onClick={() => runtime.runFork(placeOrder)}>Place order</button>;
};

Drafter

Drafter: Context.Reference<DrafterService>; // default: mutativeDrafter

A Context.Reference on the same terms as Devtools: total to read, so installing nothing costs nothing. Every handler's snapshot.draft is made through it. mutative is a dependency of the library, so the default needs no installation.

interface DrafterService {
  readonly create: <T>(base: T) => DraftHandle<T>;
}

interface DraftHandle<T> {
  readonly draft: Draft<T>;
  readonly finish: () => T;
}

One method. create(base) opens a draft over base and returns the proxy plus the call that closes it: finish() returns base by reference when nothing was written, a new value otherwise.

import { Drafter, drafterLayer } from "@wych/react";
import type { Draft, DraftHandle, DrafterService } from "@wych/react";

const cloneDrafter: DrafterService = {
  create<T>(base: T): DraftHandle<T> {
    const draft = structuredClone(base);
    return { draft: draft as Draft<T>, finish: () => draft };
  },
};

const { component: withCloneDrafter } = createRuntime(
  Layer.mergeAll(CheckoutLayer, drafterLayer(cloneDrafter)),
);

drafterLayer(service) is Layer<never>, so merging it into the root layer changes no type. Drafter's default is mutativeDrafter: Mutative with enableAutoFreeze, so a state that went through a draft is deep-frozen afterwards.

A custom drafter: Immer

Immer is not a dependency of the library. A user who wants it instead of Mutative writes the same one method with createDraft and finishDraft.

import { createDraft, finishDraft } from "immer";
import { drafterLayer } from "@wych/react";
import type { DrafterService } from "@wych/react";

const immerDrafter: DrafterService = {
  create: (base) => {
    const draft = createDraft(base);
    return { draft, finish: () => finishDraft(draft) };
  },
};

const { component } = createRuntime(Layer.mergeAll(CheckoutLayer, drafterLayer(immerDrafter)));

Known limitation. The store reads Drafter from the mount's own context once that context exists, and caches it from then on. Until the context exists, an async root layer still building, every fold drafts with the default. A custom drafter installed at the root is not seen by a fold that lands before the root layer resolves.

Server rendering

renderToString paints initialState(props), validates props, and resolves useFeature fragments. Nothing folds: no Mounted and no commands. The store starts in an effect, and effects do not run on the server. See Render on the server.

Edit this page on GitHub