Lean, schema-first state machines for Effect
Important
If you're greenfield, reach for effect/unstable/machine — the first-party Effect statechart engine — not effstate. It's more capable (hierarchical & parallel states, child machines, cluster persistence) and it's where Effect's state-machine story is headed.
effstate is for teams who need a lean, flat FSM on effect@^3 today, with synchronous React hooks and Convex/persistence sync — before native Machine stabilizes on Effect 4.0. The two share the same Effect + Schema substrate, so effstate is designed to interoperate with (and graduate to) native Machine rather than lock you in. See Relationship to Effect's native Machine.
effstate is a state machine library built on top of the Effect ecosystem. The v4 API is schema-first: you define states and events once with Effect Schema and get constructors, type guards, discriminated-union types, and serialization for free. Entry/exit logic and long-running work are plain Effects and Streams, complete with the requirements (R) and error (Err) channels — so dependency injection, resource safety, and honest error handling come along for the ride.
- Schema-first: Define states and events with
State()/Event()— one source of truth for types, runtime validation, and serialization - Type-safe: Full TypeScript inference over discriminated unions of states, events, and context
- Effect-native: Entry/exit effects and run streams carry the full Effect
R(requirements) andErr(error) channels - Auto-cleanup:
runstreams cancel automatically when the machine leaves a state - Honest errors: Effect failures surface through an
onErrorcallback instead of being swallowed - Minimal: ~500 lines, zero dependencies beyond Effect
- React-ready: Imperative actor API with first-class hooks for React
| Metric | effstate | XState |
|---|---|---|
| Bundle size (gzip) | ~6 kB | 13.7 kB |
| Event processing | 25x faster | - |
| Realistic app lifecycle | 5x faster | - |
Effect is adding a first-party statechart engine — effect/unstable/machine (currently unstable, targeting Effect 4.0). It is a larger, XState-class engine: hierarchical and parallel states, invoked/spawned child machines, first-class snapshot encode/decode, and an AtomMachine reactive adapter (built on effect-atom) plus a ClusterMachine persistence adapter.
effstate is not a competitor in a separate ecosystem — it shares Effect's. Both model states and events as Effect Schema tagged unions, and both run entry / exit / effect logic on the Effect runtime with the same R (requirements) and Err (error) channels. That makes them interoperable rather than mutually exclusive:
- Same wire format. effstate snapshots encode through Effect Schema, so a persisted or synced snapshot is the same tagged-union shape a native
Machinestate schema decodes — no adapter or impedance layer. - Same runtime & dependency injection.
entry/exit/runare plainEffect/Stream; they shareServiceMaplayers, resource scopes, and the fiber runtime with any nativeMachine. - Client/edge vs. server/cluster. Run effstate at the React edge (synchronous
useActor, noAsyncResultceremony) while a server-authoritative nativeMachineowns hierarchy and clustering — and sync snapshots between them via schema encode/decode.
Choose effstate when you want a lean, flat FSM that ships today on effect@^3, with first-class React hooks and Convex/persistence sync. Reach for native Machine when you need hierarchical/parallel states, child machines, or Effect Cluster persistence — and because both speak Effect + Schema, graduating a machine later is a projection, not a rewrite.
Watch state machines sync across browser tabs in real-time!
| Package | Description |
|---|---|
@handfish/effstate-v4 |
Core schema-first state machine library |
@handfish/effstate-react |
React hooks for effstate-v4 |
npm install @handfish/effstate-v4 effect
# or
pnpm add @handfish/effstate-v4 effectStates and events are defined with State() and Event(). Each definition bundles an Effect Schema, a .make() constructor, and an .is() type guard. Event handlers are pure functions (context, event) => Transition.
import {
State,
Event,
Union,
defineMachine,
type StateType,
type EventType,
} from "@handfish/effstate-v4";
import { Duration, Effect, Schedule, Schema, Stream } from "effect";
// =============================================================================
// 1. Define states (schema-first discriminated union)
// =============================================================================
const Disconnected = State("Disconnected", {});
const Connecting = State("Connecting", { startedAt: Schema.DateFromSelf });
const Connected = State("Connected", { connectedAt: Schema.DateFromSelf });
const ConnectionStateSchema = Union(Disconnected, Connecting, Connected);
type ConnectionState =
| StateType<typeof Disconnected>
| StateType<typeof Connecting>
| StateType<typeof Connected>;
// =============================================================================
// 2. Define events
// =============================================================================
const Connect = Event("Connect", { uri: Schema.String });
const ConnectSuccess = Event("ConnectSuccess", {});
const ConnectError = Event("ConnectError", { message: Schema.String });
const Ping = Event("Ping", {});
const Disconnect = Event("Disconnect", {});
type ConnectionEvent =
| EventType<typeof Connect>
| EventType<typeof ConnectSuccess>
| EventType<typeof ConnectError>
| EventType<typeof Ping>
| EventType<typeof Disconnect>;
// =============================================================================
// 3. Define context (a plain type; add a Schema for serialization/sync)
// =============================================================================
interface ConnectionContext {
readonly uri: string;
readonly retryCount: number;
readonly lastPingAt: number;
}
const ConnectionContextSchema = Schema.Struct({
uri: Schema.String,
retryCount: Schema.Number,
lastPingAt: Schema.Number,
});
// A `run` stream produces events while in a state and auto-cancels on exit.
const healthCheckStream = Stream.fromSchedule(
Schedule.spaced(Duration.seconds(5))
).pipe(Stream.map(() => Ping.make()));
// =============================================================================
// 4. Define the machine
// =============================================================================
const connectionMachine = defineMachine<
ConnectionState,
ConnectionContext,
ConnectionEvent
>({
id: "connection",
initialState: Disconnected.make(),
initialContext: { uri: "", retryCount: 0, lastPingAt: 0 },
context: ConnectionContextSchema, // optional — enables serialization & cross-tab sync
states: {
Disconnected: {
on: {
// Transition to a new state and update context in one step.
Connect: (_ctx, event) => ({
goto: Connecting.make({ startedAt: new Date() }),
update: { uri: event.uri },
}),
},
},
Connecting: {
// Entry effects have full access to the requirements & error channels.
entry: (snap) => Effect.log(`Connecting to ${snap.context.uri}...`),
on: {
ConnectSuccess: () => ({
goto: Connected.make({ connectedAt: new Date() }),
}),
// A guard is just a handler that returns `null` to stay put.
ConnectError: (ctx) =>
ctx.retryCount < 3
? {
goto: Connecting.make({ startedAt: new Date() }),
update: { retryCount: ctx.retryCount + 1 },
}
: { goto: Disconnected.make() },
},
},
Connected: {
entry: () => Effect.log("Connected!"),
exit: () => Effect.log("Disconnecting..."),
run: healthCheckStream, // starts on entry, cancels on exit
on: {
// Stay in the current state, only update context.
Ping: (ctx) => ({ update: { lastPingAt: ctx.lastPingAt + 1 } }),
Disconnect: () => ({ goto: Disconnected.make() }),
},
},
},
// Global handlers run in any state.
global: {
Disconnect: () => ({ goto: Disconnected.make() }),
},
});
// =============================================================================
// 5. Interpret and use
// =============================================================================
// `interpret()` returns an Effect that requires the machine's R services.
// With no dependencies, run it synchronously.
const actor = Effect.runSync(connectionMachine.interpret());
actor.subscribe((snap) => {
console.log(`State: ${snap.state._tag}, retries: ${snap.context.retryCount}`);
});
actor.send(Connect.make({ uri: "ws://localhost:3000" }));
actor.send(ConnectSuccess.make());
// Health checks now run automatically until the state changes.
actor.send(Disconnect.make()); // health-check stream is cancelled on exit
actor.stop(); // stop the actor and clean up all resourcesEvent handlers return a transition describing what should happen:
{ goto: NewState.make() } // move to a new state
{ goto: NewState.make(), update: { count: 0 } } // move + update context
{ update: { count: ctx.count + 1 } } // stay, update context
{ actions: [() => console.log("clicked")] } // stay, run side-effect actions
null // stay, no changes (acts as a guard)entry, exit, and run are Effects/Streams, so they can require services and fail. Provide the services when you interpret the machine, and pass onError to observe failures:
import { Effect } from "effect";
const program = Effect.gen(function* () {
const actor = yield* machine.interpret({
snapshot: savedSnapshot, // optional: restore from a saved snapshot
onError: (error) => {
// error.effectType: "entry" | "exit" | "run"
console.error(error.effectType, error.stateTag, error.cause);
},
interruptEntryOnTransition: false, // default: entry effects run to completion
});
actor.send(SomeEvent.make());
});
// Provide the R services the entry/exit/run effects require, then run.
Effect.runPromise(program.pipe(Effect.provide(SomeService.Default)));npm install @handfish/effstate-react @handfish/effstate-v4 effect reactuseActor creates and manages the actor for a machine definition, re-rendering on every snapshot change:
import { State, Event, defineMachine, type StateType, type EventType } from "@handfish/effstate-v4";
import { useActor } from "@handfish/effstate-react";
import { Schema } from "effect";
const Idle = State("Idle", {});
const Running = State("Running", {});
type CounterState = StateType<typeof Idle> | StateType<typeof Running>;
const Start = Event("Start", {});
const Stop = Event("Stop", {});
const Tick = Event("Tick", {});
type CounterEvent = EventType<typeof Start> | EventType<typeof Stop> | EventType<typeof Tick>;
interface CounterContext {
count: number;
}
const counterMachine = defineMachine<CounterState, CounterContext, CounterEvent>({
initialState: Idle.make(),
initialContext: { count: 0 },
states: {
Idle: {
on: { Start: () => ({ goto: Running.make() }) },
},
Running: {
on: {
Stop: () => ({ goto: Idle.make() }),
Tick: (ctx) => ({ update: { count: ctx.count + 1 } }),
},
},
},
});
function Counter() {
const { state, context, send } = useActor(counterMachine);
return (
<div>
<p>State: {state._tag} — Count: {context.count}</p>
{Idle.is(state) ? (
<button onClick={() => send(Start.make())}>Start</button>
) : (
<>
<button onClick={() => send(Tick.make())}>Tick</button>
<button onClick={() => send(Stop.make())}>Stop</button>
</>
)}
</div>
);
}| Hook | Purpose |
|---|---|
useActor(definition, options?) |
Create and manage an actor; returns { state, context, stateTag, send, actor, snapshot } |
useActorEffect(actor, effect, deps?) |
Run a side effect whenever the snapshot changes |
useActorWatch(actor, selector, onChange, deps?) |
Fire a callback when a derived value changes |
useActorSync(actor, externalSnapshot, options) |
Sync with an external source (persistence, cross-tab sync) |
useActorBridge(source, target, selector, toEvent, deps?) |
Send events to a target actor when a source actor changes |
See the @handfish/effstate-react README for full hook signatures.
Visit the documentation site for:
This is a monorepo managed with Turborepo and pnpm.
# Install dependencies
pnpm install
# Build all packages
pnpm build
# Run tests
pnpm test
# Run a demo app
pnpm --filter demo-dexie-v4 dev
# Run the docs site
pnpm --filter docs dev.
├── packages/
│ ├── effstate-v4/ # @handfish/effstate-v4 - core state machine library
│ └── effstate-react/ # @handfish/effstate-react - React integration
├── apps/
│ ├── demo-dexie-v4/ # v4 demo with Dexie persistence & cross-tab sync
│ ├── demo-convex-order-v4/ # v4 demo backed by Convex
│ └── docs/ # Astro Starlight documentation site
└── assets/ # Shared assets (logo, etc.)
Contributions are welcome! Please read our contributing guidelines before submitting a PR.
MIT
