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 }): FeatureComponentThe 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: mutativeDrafterA 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.