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));
// => undefined

As 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 component

The 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.ts

npm 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.

Edit this page on GitHub