React Integration
@effstate/react provides first-class React integration for effstate using @effect-atom/atom-react.
Installation
npm install @effstate/react @effect-atom/atom-reactpnpm add @effstate/react @effect-atom/atom-reactBasic Setup
1. Create Your App Runtime
First, create a runtime with your service layers:
import { Atom } from "@effect-atom/atom-react";import { Layer, Logger } from "effect";import { CounterMachine } from "./machines/counter";
const AppLayer = Layer.mergeAll( Logger.pretty, CounterMachine.Default,);
export const appRuntime = Atom.runtime(AppLayer);2. Create Atoms for Your Machine
import { Effect, SubscriptionRef } from "effect";import { interpret } from "effstate";
// Actor atom - creates and manages the machine actorconst actorAtom = appRuntime .atom( Effect.gen(function* () { const service = yield* CounterMachine; return yield* service.createActor(); }) ) .pipe(Atom.keepAlive);
// Snapshot atom - subscribes to state changesconst snapshotAtom = appRuntime .subscriptionRef((get) => Effect.gen(function* () { const actor = yield* get.result(actorAtom); const ref = yield* SubscriptionRef.make(actor.getSnapshot()); actor.subscribe((snapshot) => { Effect.runSync(SubscriptionRef.set(ref, snapshot)); }); return ref; }) ) .pipe(Atom.keepAlive);3. Create the Hook
import { createUseMachineHook } from "@effstate/react";
const initialSnapshot = { value: "idle", context: { count: 0 }, event: null,};
export const useCounterMachine = createUseMachineHook( actorAtom, snapshotAtom, initialSnapshot,);4. Use in Components
import { useCounterMachine } from "./atoms/counter";import { Increment, Decrement, Reset } from "./machines/counter";
function Counter() { const { state, context, send, isLoading, matches } = useCounterMachine();
if (isLoading) return <div>Loading...</div>;
return ( <div> <p>State: {state}</p> <p>Count: {context.count}</p>
<button onClick={() => send(new Increment())}>+</button> <button onClick={() => send(new Decrement())}>-</button> <button onClick={() => send(new Reset())}>Reset</button>
{matches("active") && <p>Counter is active!</p>} </div> );}Hook API
createUseMachineHook returns a hook with the following interface:
interface UseMachineResult<TState, TContext, TEvent> { /** Current state snapshot */ snapshot: MachineSnapshot<TState, TContext>; /** Send an event to the machine */ send: (event: TEvent) => void; /** Whether the machine is still initializing */ isLoading: boolean; /** Check if machine is in a specific state */ matches: (state: TState) => boolean; /** Current state value (shorthand for snapshot.value) */ state: TState; /** Current context (shorthand for snapshot.context) */ context: TContext;}Child Machine Hooks
For parent-child machine compositions, use createUseChildMachineHook:
import { createUseChildMachineHook } from "@effstate/react";
// Parent machine spawns children:// spawnChild(GarageDoorMachine, { id: "leftDoor" })
// Create hook for the childconst useLeftDoor = createUseChildMachineHook< ParentState, ParentContext, ParentEvent, ChildState, ChildContext, ChildEvent>( appRuntime, parentActorAtom, "leftDoor", // child ID childInitialSnapshot,);
// Use in componentfunction GarageDoor() { const { state, send, isLoading } = useLeftDoor();
if (isLoading) return null;
return ( <div> <p>Door: {state}</p> <button onClick={() => send(new Open())}>Open</button> </div> );}Selectors
Use selectors for derived state:
import { selectContext, selectState } from "@effstate/react";
// Select specific context valuesconst selectCount = selectContext<CounterContext, number>( (context) => context.count);
// Check specific stateconst isActive = selectState<CounterState>("active");
// Use in componentfunction Counter() { const { snapshot } = useCounterMachine();
const count = selectCount(snapshot); const active = isActive(snapshot);
return ( <div> <p>Count: {count}</p> {active && <p>Active!</p>} </div> );}Full Example
Here’s a complete example with a timer machine:
import { createMachine, interpret, assign, effect } from "effstate";import { Data, Effect, Schema } from "effect";
class Start extends Data.TaggedClass("START")<{}> {}class Stop extends Data.TaggedClass("STOP")<{}> {}class Tick extends Data.TaggedClass("TICK")<{}> {}class Reset extends Data.TaggedClass("RESET")<{}> {}
type TimerEvent = Start | Stop | Tick | Reset;
const ContextSchema = Schema.Struct({ elapsed: Schema.Number,});
export class TimerMachine extends Effect.Service<TimerMachine>()( "TimerMachine", { effect: Effect.gen(function* () { const machine = createMachine< "idle" | "running", TimerEvent, typeof ContextSchema >({ id: "timer", initial: "idle", context: ContextSchema, initialContext: { elapsed: 0 }, states: { idle: { on: { START: { target: "running" }, RESET: { actions: [assign({ elapsed: 0 })] }, }, }, running: { activities: [ { id: "ticker", src: ({ send }) => Effect.gen(function* () { while (true) { yield* Effect.sleep("1 second"); send(new Tick()); } }), }, ], on: { TICK: { actions: [ assign(({ context }) => ({ elapsed: context.elapsed + 1 })), ], }, STOP: { target: "idle" }, }, }, }, });
return { machine, createActor: () => interpret(machine), }; }), }) {}
export const initialSnapshot = { value: "idle" as const, context: { elapsed: 0 }, event: null,};
export { Start, Stop, Reset };import { Atom } from "@effect-atom/atom-react";import { Effect, Layer, SubscriptionRef } from "effect";import { createUseMachineHook } from "@effstate/react";import { TimerMachine, initialSnapshot } from "../machines/timer";
const AppLayer = Layer.mergeAll(TimerMachine.Default);const appRuntime = Atom.runtime(AppLayer);
const actorAtom = appRuntime .atom( Effect.gen(function* () { const service = yield* TimerMachine; return yield* service.createActor(); }) ) .pipe(Atom.keepAlive);
const snapshotAtom = appRuntime .subscriptionRef((get) => Effect.gen(function* () { const actor = yield* get.result(actorAtom); const ref = yield* SubscriptionRef.make(actor.getSnapshot()); actor.subscribe((snapshot) => { Effect.runSync(SubscriptionRef.set(ref, snapshot)); }); return ref; }) ) .pipe(Atom.keepAlive);
export const useTimer = createUseMachineHook( actorAtom, snapshotAtom, initialSnapshot,);import { useTimer } from "../atoms/timer";import { Start, Stop, Reset } from "../machines/timer";
export function Timer() { const { state, context, send, isLoading, matches } = useTimer();
if (isLoading) return <div>Loading...</div>;
const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; };
return ( <div> <h1>{formatTime(context.elapsed)}</h1>
{matches("idle") ? ( <button onClick={() => send(new Start())}>Start</button> ) : ( <button onClick={() => send(new Stop())}>Stop</button> )}
<button onClick={() => send(new Reset())}>Reset</button> </div> );}Why @effect-atom?
@effect-atom/atom-react provides:
- Suspense support - Atoms can suspend while loading
- Automatic dependency tracking - Only re-renders when used values change
- Effect integration - Native support for Effect-based async operations
- SubscriptionRef - Perfect for reactive state machine snapshots
- keepAlive - Atoms persist across component unmounts
This makes it ideal for integrating Effect-based state machines with React.