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 Added = Action("Added", { sku: Schema.String });
const Ordered = Action("Ordered", { orderId: Schema.String });
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) }),
action: Action.of([Added, Ordered]),
output: Action.of([OrderPlaced]),
});
export const cart = Cart.create({
initialState: Cart.initialState(() => ({ items: [] })),
reducer: Cart.reducer({
Added: ({ sku }, { state, props }) => [
{ items: [...state.items, sku] },
Command.effect((dispatch) =>
Effect.flatMap(Checkout, (checkout) => checkout.place(props.customerId)).pipe(
Effect.flatMap((orderId) => dispatch({ _tag: "Ordered", orderId })),
),
),
],
Ordered: ({ orderId }, { state }) => [state, Command.output(OrderPlaced, { orderId })],
}),
render: Cart.render(({ state, dispatch }) => (
<button onClick={() => dispatch({ _tag: "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 covered by the root.
The second takes a feature that needs more, and a layer supplying the
residue 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 }, { state }) => [
{ items: [...state.items, sku] },
Command.effect(() => Effect.flatMap(Analytics, (a) => a.track(sku))),
],
Ordered: ({ orderId }, { state }) => [state, Command.output(OrderPlaced, { orderId })],
}),
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);The feature layer is built once per mount and released when that mount closes. Anything that must outlive a mount belongs in the root layer.
name
name defaults to "WychFeature". It appears in the component's
displayName, in useFeature error messages, and as the name field of every
devtools event.
const Anonymous = component(cart);
console.log(CartView.displayName);
// => "Cart"
console.log(Anonymous.displayName);
// => "WychFeature"Output props
Every declared output becomes a required on<Tag> prop. The payload arrives
with _tag stripped, because 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. Absence is unreachable through JSX, since 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. Props are validated, never decoded: define
normalizes the props schema to its Type side, so a transforming field is
never re-decoded on a parent render.
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({ _tag: "Added", sku: "sku_2" })}>
{state.items.length} items
</button>
);
};dispatch accepts declared actions and declared outputs. It is reference-stable
for the life of the mount.
const AnnounceButton = () => {
const { dispatch } = CartView.useFeature();
// An output dispatched from the view leaves through onOrderPlaced.
return <button onClick={() => dispatch({ _tag: "OrderPlaced", orderId: "o_1" })}>Ship</button>;
};Called outside a mount of that component, useFeature throws.
renderToString(<ItemCount />);
// throws TypeError: Cart.useFeature() called outside <Cart>Two component() calls over one feature have separate contexts, so
Anonymous.useFeature() under <CartView> throws. Do not call useFeature
inside render; render already has the snapshot as its argument.
useRuntime
useRuntime(): ManagedRuntime.ManagedRuntime<RootR, RootE>The escape hatch for plain React components that are not features.
const PlaceOrderButton = () => {
const runtime = useRuntime();
return (
<button
onClick={() => runtime.runFork(Effect.flatMap(Checkout, (checkout) => checkout.place("c_1")))}
>
Place order
</button>
);
};Server rendering
renderToString paints initialState(props), validates props, and resolves
useFeature fragments. Nothing folds: no Mounted, no commands, no store
arming, because the arming lives in an effect. See
Render on the server.