@wych/react
Effect v4 · React 19

Pure reducers for React, with Effect doing the work.

Lifecycle, async and cancellation fold through one reducer. Test it without a renderer.

npm install @wych/react effect react react-dom
search.tsxthe feature
import { Action, createRuntime, define, Task } from "@wych/react";
import { Context, Effect, Layer, Schema } from "effect";

const Hits = Schema.Array(Schema.String);

export class SearchApi extends Context.Service<
  SearchApi,
  { readonly hits: (query: string) => Effect.Effect<ReadonlyArray<string>> }
>()("SearchApi") {}

export const Typed = Action("Typed", { query: Schema.String });
const Cleared = Action("Cleared", {});

// Two actions (SearchResolved, SearchRejected) and one cancellable command.
const search = Task("Search", {
  success: Hits,
  onError: Task.message,
  run: (query: string) =>
    Effect.gen(function* () {
      const api = yield* SearchApi;
      return yield* api.hits(query);
    }),
});

export const taskSearch = define({
  props: Schema.Struct({}),
  state: Schema.Struct({ query: Schema.String, results: Task.schema(Hits) }),
  action: Action.of([Typed, Cleared, ...search.actions]),
}).create({
  initialState: () => ({ query: "", results: Task.idle }),
  reducer: {
    // Take latest: a new Typed interrupts the fiber still resolving the old one.
    Typed: ({ query }, { state }) =>
      Task.start({ ...state, query }, "results", search.run(query)),
    Cleared: (_payload, { state }) =>
      [{ ...state, query: "", results: Task.idle }, search.cancel],
    SearchResolved: ({ value }, { state }) =>
      ({ ...state, results: Task.resolved(value) }),
    SearchRejected: ({ error }, { state }) =>
      ({ ...state, results: Task.rejected(error) }),
  },
  render: ({ state, dispatch }) => (
    <div>
      <input
        value={state.query}
        onChange={(e) => dispatch(Typed.make({ query: e.target.value }))}
      />
      {Task.match(state.results, {
        Idle: () => null,
        Pending: () => <p>Searching</p>,
        Rejected: ({ error }) => <p>{error}</p>,
        Resolved: ({ value }) => <ul>{value.map((h) => <li key={h}>{h}</li>)}</ul>,
      })}
    </div>
  ),
});

const live = Layer.succeed(SearchApi)({
  hits: (query) => Effect.succeed([`${query} result`]),
});
const { component } = createRuntime(live);
export const Search = component(taskSearch, { name: "Search" });
search.test.tsthe proof: no renderer, no mocks
import { Effect, Layer } from "effect";
import { expect, test } from "vitest";
import { SearchApi, taskSearch, Typed } from "./search";

// Slow enough that "a" is still in flight when "ab" arrives.
const slowApi = Layer.succeed(SearchApi)({
  hits: (query) => Effect.sleep("50 millis").pipe(Effect.as([`${query}!`])),
});

test("a newer keystroke interrupts the request in flight", async () => {
  const { state, emitted } = await Effect.runPromise(
    taskSearch.run([Typed.make({ query: "a" }), Typed.make({ query: "ab" })], {
      props: {},
      hooks: {},
      layer: slowApi,
    }),
  );

  expect(emitted).toEqual([{ _tag: "SearchResolved", value: ["ab!"] }]);
  expect(state.results).toEqual({ _tag: "Resolved", value: ["ab!"] });
});

Two keystrokes, one slow API, one result. run seeds the actions, runs every command against the layer, folds what they dispatch, and resolves when nothing is left running. The same feature, unchanged, is a React component.

Wych is not a store, not atoms, and not a server cache. It sits next to those.

See use with the React ecosystem (TanStack Query, routers, stores) and how it compares.

Lifecycle without useEffect
Mounted, PropsChanged, Unmounted and Error arrive in the reducer like any other action. Startup work, a resubscribe when a prop changes, a flush on exit: each is a handler returning [state, command]. No dependency array, no cleanup closure.
Fibers have names
The only leaf of a Command is an Effect. Book it under a key and any later handler can cancel or restart it by that name. That is the runtime’s one supervisory concept. Debounce, throttle and retry are the Effect combinators you already have.
A feature is a value
reduce folds one action. run folds a sequence to its end against a Layer and reports state, emissions and outputs. component mounts the same value in React. renderToString paints initial state and folds nothing.

Written by agents, checked by the compiler

A coding agent gets the same deal you do: a small typed surface that reads as a spec, and a proof that runs with no browser. Use with AI agents.

The definition is the spec
define({ props, state, action, output }) is a feature’s whole contract on one screen: what comes in, what it holds, what it can do, what it tells its parent. No useEffect graph to reconstruct, no state hiding in closures.
Wrong is a type error
Schemas type props, state and payloads. The reducer needs one handler per action tag, required and exhaustive. Outputs are required on<Tag> props at every JSX call site. An agent’s mistake fails tsc before it reaches a user.
It can check its own work
run folds a feature to its end in Node against a test Layer, so an agent verifies async logic without a browser. The docs ship inside the package and at /llms.txt, so it reads the version you installed.

Documentation

All pages
Tutorial
One app in three chapters: a feature, then async work, then a parent that hears its children.
Reference
Every export, one page per area, with its contract and a snippet that calls it.