The model
A login form in React starts small. Two inputs, a button, a request. Then the button has to disable while the request is out, the error has to render, a second click has to be ignored, and an unmount mid-request has to drop the response. Each rule lands in a different hook.
import { useEffect, useRef, useState } from "react";
declare function signIn(email: string, password: string): Promise<string>;
export const LoginForm = ({ onSignedIn }: { readonly onSignedIn: (userId: string) => void }) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const alive = useRef(true);
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
const submit = async () => {
if (pending) return;
setPending(true);
setError(undefined);
try {
const userId = await signIn(email, password);
if (alive.current) onSignedIn(userId);
} catch (thrown) {
if (alive.current) setError(String(thrown));
} finally {
if (alive.current) setPending(false);
}
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<input value={email} onChange={(event) => setEmail(event.target.value)} />
<input value={password} onChange={(event) => setPassword(event.target.value)} />
<button disabled={pending}>Sign in</button>
{error && <p role="alert">{error}</p>}
</form>
);
};Nothing here is wrong. The problem is where the rules live. pending and
error can disagree, because two useState calls have no shared
transition. The double-submit guard reads a closure that may be stale. The
alive ref exists because React has no name for "the request this component
started". And none of it can be exercised without rendering the form.
Elm answered this in 2012: state is a fold over messages, and work is a value the fold returns. Wych is that answer on React, with Effect as the work.
The reducer describes, the runtime does
A feature is declared from values. Props and state are Schema.Structs, and
the actions are messages, one per tag. A Task declares the request as two
actions and a command; the tasks slot gives it the state field that holds
the result, and mode: "first" is the double-submit rule.
import { Context, Effect, Layer, Schema } from "effect";
import { Action, Command, createRuntime, define, Next, Task } from "@wych/react";
class Auth extends Context.Service<
Auth,
{ readonly signIn: (email: string, password: string) => Effect.Effect<string, Error> }
>()("Auth") {}
const actions = Action({
EmailTyped: { email: Schema.String },
PasswordTyped: { password: Schema.String },
Submitted: {},
});
const SignedIn = Action.output("SignedIn", { userId: Schema.String });
const login = Task("Login", {
success: Schema.String,
mode: "first",
run: (credentials: { readonly email: string; readonly password: string }) =>
Effect.gen(function* () {
const auth = yield* Auth;
return yield* auth.signIn(credentials.email, credentials.password);
}),
});
const Login = define({
props: Schema.Struct({}),
state: Schema.Struct({ email: Schema.String, password: Schema.String }),
tasks: { session: login },
actions,
outputs: SignedIn,
});That block is the whole contract: what the feature holds, what it can do,
what it tells its parent. tasks: { session: login } adds session to the
state, a TaskValue that starts Idle, and the two settle actions to the
reducer. The React version spread the same facts over four useState calls,
a ref and a callback prop.
The reducer is one pure function of (payload, snapshot). A handler returns
the next state, or the next state beside a Command.
A draft does not change this. snapshot.draft is a mutable view of state;
the handler still returns the next state, now written by mutating the draft
instead of spreading it. The runtime finishes the draft into a plain frozen
value before anything else reads it, and nothing outside the handler ever
sees the proxy.
const loginForm = Login.create({
initialState: () => ({ email: "", password: "" }),
reducer: {
EmailTyped: ({ email }, { draft }) => {
draft.email = email;
return draft;
},
PasswordTyped: ({ password }, { draft }) => {
draft.password = password;
return draft;
},
Submitted: (_payload, { state, tasks }) =>
tasks.session.start({ email: state.email, password: state.password }),
LoginResolved: ({ value }, { state }) => [state, Command.output(SignedIn, { userId: value })],
},
render: ({ state, dispatch }) => (
<form
onSubmit={(event) => {
event.preventDefault();
dispatch(actions.Submitted);
}}
>
<input
value={state.email}
onChange={(event) => dispatch(actions.EmailTyped, { email: event.target.value })}
/>
<input
value={state.password}
onChange={(event) => dispatch(actions.PasswordTyped, { password: event.target.value })}
/>
<button disabled={Task.isPending(state.session)}>Sign in</button>
{Task.isRejected(state.session) && <p role="alert">{state.session.error}</p>}
</form>
),
});Every rule from the React version is in the reducer. pending and error
are one field with four cases, so they cannot disagree. The double-submit
guard is mode: "first" on the task: the runtime knows which request it
started and drops a start while that request is in flight, so no closure is
read and none can go stale. The
alive ref is gone: state lives at the store, not the component, so a
login that resolves after unmount folds safely into state nothing renders,
instead of calling setState on a component that is gone.
The Submitted handler does not sign in. It returns a description of signing
in, and tasks.session.start writes Pending beside it on the same fold.
Who runs the description is the runtime's business. When the request
settles, the fold writes Resolved or Rejected into session before any
handler runs. LoginRejected needs no handler; LoginResolved does one
more thing, so it is written: it announces SignedIn beside the field
write.
One reducer, three readers
feature.reduce is the reducer as a function. No React, no Effect runtime,
no service. It answers "given this state and this action, what happens next"
and hands the command back as data.
const pending = {
state: { email: "ada@example.com", password: "hunter2", session: Task.pending },
props: {},
hooks: {},
};
const resubmitted = loginForm.reduce(actions.Submitted.make(), pending);
console.log(Next.state(resubmitted) === pending.state);
// => true
console.log(Next.command(resubmitted)?._tag);
// => "Keyed"The field is already Pending, so the state comes back untouched. The
command still comes back: whether it runs is the runtime's decision, made
against the request in flight.
feature.run folds a sequence, runs each command against a Layer, feeds
what a command dispatches back in, and resolves when nothing is left
running. The React version could show this only through a rendered form and
a mocked network.
const auth = Layer.succeed(Auth)({
signIn: (email) => Effect.succeed(`user_${email.split("@")[0]}`),
});
const signedIn = await Effect.runPromise(
loginForm.run(
[
actions.EmailTyped.make({ email: "ada@example.com" }),
actions.PasswordTyped.make({ password: "hunter2" }),
actions.Submitted.make(),
],
{ props: {}, hooks: {}, layer: auth },
),
);
console.log(signedIn.emitted);
// => [{ _tag: "LoginResolved", value: "user_ada" }]
console.log(signedIn.outputs);
// => [{ _tag: "SignedIn", userId: "user_ada" }]
console.log(signedIn.state.session);
// => { _tag: "Resolved", value: "user_ada" }component is the React binding. It mounts the feature, folds every
dispatch, forks every command into the mount's scope, and paints render.
The output becomes a required onSignedIn prop.
const { component } = createRuntime(auth);
export const LoginView = component(loginForm, { name: "Login" });
// <LoginView onSignedIn={({ userId }) => navigate(`/users/${userId}`)} />All three read commands through one interpreter, so grouping and
cancellation have one implementation and Next.command is the one place a
lazy command
resolves. A test written with run therefore measures the
behaviour the mounted component has. Two interpreters would have to agree
forever, and they would drift.
Where React state hooks fit
Feature state lives in the feature. render holds no useState, no
useReducer and no useEffect; a view fragment under the mount reads the
same snapshot through LoginView.useFeature().
const SubmitButton = () => {
const { state, dispatch } = LoginView.useFeature();
return (
<button disabled={Task.isPending(state.session)} onClick={() => dispatch(actions.Submitted)}>
Sign in
</button>
);
};Two reasons for the rule. State kept in React is invisible to reduce and
run, so it cannot be tested headless or shown in devtools. Work started in
useEffect is outside the fiber book, so no action can cancel it and unmount
does not know it exists.
Some inputs only exist in React: a router, a media query, a data-fetching
hook from another library. useUnsafeHooks calls them in render position and
hands the values to the reducer as hooks, and a change raises
HookChanged.
import { Schema } from "effect";
import { Action, define } from "@wych/react";
declare function useOnlineStatus(): boolean;
const EmailTyped = Action("EmailTyped", { email: Schema.String });
const Login = define({
props: Schema.Struct({}),
state: Schema.Struct({ email: Schema.String, offline: Schema.Boolean }),
actions: [EmailTyped],
useUnsafeHooks: () => ({ online: useOnlineStatus() }),
});
const loginForm = Login.create({
initialState: () => ({ email: "", offline: false }),
reducer: {
EmailTyped: ({ email }, { draft }) => {
draft.email = email;
return draft;
},
HookChanged: (_payload, { draft, hooks }) => {
draft.offline = !hooks.online;
return draft;
},
},
render: ({ state }) => (state.offline ? "offline" : state.email),
});It is named unsafe because it opens the feature to whatever the hook does. A
hook that reads ambient input is fine. A hook that owns state the feature
should own moves the feature back into React, one useState at a time.
Two kinds of work, kept apart
Elm keeps commands and subscriptions apart, for a reason worth carrying over:
a command is issued and finishes, a subscription is declared and the
runtime keeps it running until the feature stops declaring it. Wych keeps the
same split. Command.effect is a leaf that runs once and settles. A feature
that needs a websocket, a presence feed or a Stream.tick declares it through
a subscriptions hook on create, keyed on the snapshot; the runtime diffs
the declared keys after every fold and starts or stops fibers to match. See
Subscriptions for the hook and the key rule.
The split decides where each is declared. A task binding is static: its
field, its two actions and its group are fixed before any state exists, so
tasks sits on define beside the state schema and the vocabularies, and
initialState, the reducer and the snapshot types all read it. A
subscription set is a function of the snapshot, computed again after every
fold, so subscriptions sits on create beside the reducer that produces
those snapshots.
run resolves at command quiescence: nothing queued, no command fiber in
flight. A subscription fiber does not count. A subscription over a
synchronous stub, Stream.fromArray or Stream.make, has already emitted by
the time run resolves, so it is testable with run on the same terms as a
command. An endless subscription, Effect.never or a live stream, holds
nothing open: run interrupts every subscription fiber it started once
command work settles.
A command that never completes is a different case. It still keeps run
open, because a command that runs forever has not finished, and finishing is
what makes it a command. That is a long-lived source written in the wrong
place; move it into a subscription. See
groups and cancellation for how
a command is booked and cancelled, and
commands as data for why a command is a
value in the first place.