Your first feature
You build a note editor. It holds the text of one note, tracks whether the text moved away from what the parent passed, and reverts on demand.
State lives in the feature. By the end of this page you have three files and a feature you can fold in a test with no DOM.
1. Create the runtime
The runtime is the root of everything Wych mounts. It takes one Layer, the
services your commands ask for. This feature asks for nothing, so the layer is
empty.
// runtime.ts
import { Layer } from "effect";
import { createRuntime } from "@wych/react";
export const { component } = createRuntime(Layer.empty);createRuntime also returns Provider and useRuntime. You do not need them
yet.
2. Declare the shapes
define declares what a feature is made of. Props and state are
Schema.Structs. Actions are messages, declared as one record keyed by tag.
Outputs, the fourth declaration, wait until
chapter 3.
// note-editor.tsx
import { Schema } from "effect";
import { Action, define } from "@wych/react";
const actions = Action({
TextChanged: { text: Schema.String },
Reverted: {},
});
const Editor = define({
props: Schema.Struct({ noteId: Schema.String, initialText: Schema.String }),
state: Schema.Struct({ text: Schema.String, dirty: Schema.Boolean }),
actions,
});Action turns each key into a message schema: actions.TextChanged carries
a text field, actions.Reverted carries nothing. Editor is a definition.
It hands back four helpers (initialState, reducer, render, create),
each already typed against these schemas.
3. Write the initial state
initialState receives the props the parent passed on mount.
const initialState = Editor.initialState((props) => ({
text: props.initialText,
dirty: false,
}));4. Write the reducer
One handler per action tag, exhaustive. A handler receives the action's payload and a snapshot.
const reducer = Editor.reducer({
TextChanged: (payload, snapshot) => {
snapshot.draft.text = payload.text;
snapshot.draft.dirty = payload.text !== snapshot.props.initialText;
return snapshot.draft;
},
Reverted: (_payload, snapshot) => {
snapshot.draft.text = snapshot.props.initialText;
snapshot.draft.dirty = false;
return snapshot.draft;
},
});The reducer is pure. It returns the next state. Nothing here runs an effect and
nothing mutates snapshot.state.
Each handler above writes into snapshot.draft, a mutable view of state,
and returns it. The runtime finishes the draft into the next state before
anything else reads it; see Features for the
rules draft follows.
payload is the action without its _tag: for TextChanged that is
{ text }, for Reverted it is {}. snapshot is
{ state, props, hooks, draft }. Later chapters destructure both,
({ text }, { draft, props }), once the shape is familiar.
5. Write the view
render receives the same snapshot the reducer does, plus dispatch. The
text stays in state, where the reducer and the test in step 8 can see it.
A useState inside render would hide it from both. dispatch takes a
message and its payload. A message with no fields takes no payload.
const render = Editor.render(({ state, dispatch }) => (
<form>
<textarea
value={state.text}
onChange={(event) => dispatch(actions.TextChanged, { text: event.target.value })}
/>
<button type="button" disabled={!state.dirty} onClick={() => dispatch(actions.Reverted)}>
Revert
</button>
</form>
));6. Build the feature and its component
create turns the three pieces into a Feature. component turns the feature
into a React component.
const editor = Editor.create({ initialState, reducer, render });
export const NoteEditor = component(editor, { name: "NoteEditor" });name appears in error messages, in React devtools as displayName, and in
every Wych devtools event. It is required, so pick one that tells two
features apart.
7. Mount it
NoteEditor is an ordinary React component. Its props are the props schema.
// main.tsx
import { createRoot } from "react-dom/client";
const root = createRoot(document.getElementById("root")!);
root.render(<NoteEditor noteId="n1" initialText="Buy milk" />);Type in the textarea and the button enables. Press it and the text returns to
"Buy milk".
Props are validated on mount and whenever the props identity changes. An extra
prop or a wrong type throws a TypeError to the nearest error boundary.
8. Test it without React
feature.reduce is the reducer as one pure function. It needs no DOM and no
Effect runtime, so a test hands it an action and a snapshot and reads what
came back. reduce takes a built message: make builds one from the payload.
import { Next } from "@wych/react";
const next = editor.reduce(actions.TextChanged.make({ text: "Buy oats" }), {
state: { text: "Buy milk", dirty: false },
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
});
console.log(Next.state(next));
// => { text: "Buy oats", dirty: true }
console.log(Next.command(next));
// => undefinedAs a vitest file, that is the whole test. Nothing renders and nothing is mocked.
// note-editor.test.ts
import { Next } from "@wych/react";
import { expect, test } from "vitest";
import { actions, editor } from "./note-editor";
test("typing marks the note dirty", () => {
const next = editor.reduce(actions.TextChanged.make({ text: "Buy oats" }), {
state: { text: "Buy milk", dirty: false },
props: { noteId: "n1", initialText: "Buy milk" },
hooks: {},
});
expect(Next.state(next)).toEqual({ text: "Buy oats", dirty: true });
expect(Next.command(next)).toBeUndefined();
});reduce returns a Next: a bare state, or a [state, command] tuple.
Next.state and Next.command read either shape, so a test never matches on
the tuple. This handler returned no command, so Next.command is undefined.
The files
src/
main.tsx # createRoot and the mount
note-editor.test.ts # reduce, one action, no DOM
note-editor.tsx # define, initialState, reducer, render, create, component
runtime.ts # createRuntime, exports componentThe snippets above type-check as one module. Split across the files, the imports between them are:
import { component } from "./runtime"; // note-editor.tsx
import { NoteEditor } from "./note-editor"; // main.tsx
import { actions, editor } from "./note-editor"; // note-editor.test.tsnpm run dev starts the app and npm test runs the test. The example behind
the "Open in StackBlitz" button is this file set.
Next
The editor holds text and nothing else. Saving it needs a service, a command, and a place to put the pending state. That is chapter 2.
For the full contract of define, create, reduce and Next, see
Features. For component and props validation, see
Runtime.