@wych/react

A search box in React is a useState for the query, a useState for the results, a useEffect that fetches, a cleanup that tries to cancel, and a ref that drops the response from the keystroke before. The rules live in four places, none of them is the reducer, and none of them runs without a DOM.

Wych puts the rules in one place. A feature is a pure reducer over schema-typed state. A handler returns the next state and, when there is work to do, a Command: an Effect the runtime forks, books under a name, and interrupts when a later action says so.

A feature can also declare subscriptions: long-lived sources, keyed on state, that the runtime starts and stops by diffing the key set after every fold.

The same reducer folds under React, under a test, or by hand.

import { Context, Effect, Layer, Schema } from "effect";
import { Action, createRuntime, define, Task } from "@wych/react";

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

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

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

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

const taskSearch = define({
  props: Schema.Struct({}),
  state: Schema.Struct({ query: Schema.String }),
  // The task owns the `results` field: Idle, Pending, Resolved or Rejected.
  tasks: { results: search },
  actions,
}).create({
  initialState: () => ({ query: "" }),
  reducer: {
    // Take latest: a new Typed interrupts the fiber still resolving the old one.
    Typed: ({ query }, { draft, tasks }) => {
      draft.query = query;
      return tasks.results.start(query);
    },
    Cleared: (_payload, { draft, tasks }) => {
      draft.query = "";
      return tasks.results.cancel();
    },
  },
  render: ({ state, dispatch }) => (
    <div>
      <input
        value={state.query}
        onChange={(event) => dispatch(actions.Typed, { query: event.target.value })}
      />
      <button onClick={() => dispatch(actions.Cleared)}>clear</button>
      {Task.match(state.results, {
        Idle: () => null,
        Pending: () => <p>Searching</p>,
        Rejected: ({ error }) => <p>{error}</p>,
        Resolved: ({ value }) => (
          <ul>
            {value.map((hit) => (
              <li key={hit}>{hit}</li>
            ))}
          </ul>
        ),
      })}
    </div>
  ),
});

const api = Layer.succeed(SearchApi)({
  hits: (query) => Effect.succeed([`${query} result`]),
});

const { component } = createRuntime(api);
export const Search = component(taskSearch, { name: "Search" });

// <Search />

The fetch, the cancel and the race are in the Typed handler, as a value. tasks.results.start writes Pending on the same fold, so Searching paints before the request starts, and the settle lands in results with no handler to write. tasks.results.cancel() writes Idle and interrupts the request, so a different action can end it.

The proof does not need React. Feed two keystrokes to run with an API slow enough that the first is still in flight when the second arrives, and read what resolved.

const slowApi = Layer.succeed(SearchApi)({
  hits: (query) => Effect.sleep("50 millis").pipe(Effect.as([`${query}!`])),
});

const result = await Effect.runPromise(
  taskSearch.run([actions.Typed.make({ query: "a" }), actions.Typed.make({ query: "ab" })], {
    props: {},
    hooks: {},
    layer: slowApi,
  }),
);
console.log(result.emitted);
// => [{ _tag: "SearchResolved", value: ["ab!"] }]
console.log(result.state.results);
// => { _tag: "Resolved", value: ["ab!"] }

One result, from the last keystroke. The "a" request was interrupted, and an interrupted task dispatches nothing.

Three consumers read the same reducer through one command interpreter: feature.reduce folds one action, feature.run folds a sequence until nothing is left running, and component mounts it. A test written with run measures the behaviour the mounted component has.

Status

Alpha, on Effect v4 release candidates. run resolves at command quiescence: a never-completing command holds it open, so a long-lived source belongs in a subscription instead. Explained in commands as data.

Install

npm install @wych/react effect react react-dom

effect (v4), react and react-dom are peer dependencies.

Tutorial

One app, three chapters. Start here.

  • Your first feature: a note editor with a dirty flag, mounted in React and folded in a test with no DOM.
  • Async work: save through a service, hold the pending, resolved and rejected states in one field.
  • Composing features: a list that mounts many editors and hears each one save.

How-to

Reference

  • Runtime: createRuntime, component, useFeature, output props, props validation.
  • Features: define, create, reduce, run, Next, Children.
  • Actions and outputs: Action, Action.output, the record form, the two channels.
  • Commands: every constructor, groups, the contextual typing rule.
  • Subscriptions: Subscription.effect, the subscriptions hook, the key rule, run and subscriptions.
  • Lifecycle: the five runtime actions and change detection.
  • Tasks: Task, TaskValue, the matcher and guards.
  • Devtools: the service, sinks, the event union, the recorder.

Explanation

Edit this page on GitHub