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