Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Client State

Client state is part of the Client.Service(...) definition. When a client connects, the actor runtime sends an initial state value during audition. After that, client-side reducers fold incoming events into the current state.

There is no separate state service. The client itself exposes the state stream, event stream, call surface, and typed reducer helper.

Define state on the client

import { Schema as S } from "effect"
import { Client } from "liminal"
 
export const Player = S.Literals(["X", "O"])
export const Coordinate = S.Literals([0, 1, 2])
export const Coordinates = S.Tuple([Coordinate, Coordinate])
 
export class TicTacToeClient extends Client.Service<TicTacToeClient>()("examples/TicTacToeClient", {
  state: {
    awaitingPartner: S.Boolean,
    name: Player,
  },
  events: {
    GameStarted: {},
    MoveMade: {
      player: Player,
      position: Coordinates,
    },
    GameEnded: {
      winner: S.optional(Player),
    },
  },
  external: {
    Move: {
      payload: S.Struct({ position: Coordinates }),
      success: S.Void,
      failure: S.Never,
    },
  },
}) {}

The state record is Effect Schema struct fields. The actor runtime must hydrate a value matching that shape for every new connection.

Hydrate on connect

In a Workerd actor runtime, hydrate returns the initial state for the connecting client.

import { Effect } from "effect"
 
import { TicTacToeActor } from "./TicTacToeActor.ts"
 
export default Effect.gen(function* () {
  const { clients } = yield* TicTacToeActor
  if (clients.size === 1) {
    return {
      awaitingPartner: true,
      name: "X" as const,
    }
  }
 
  yield* TicTacToeActor.others.send("GameStarted", {})
  return {
    awaitingPartner: false,
    name: "O" as const,
  }
}).pipe(Effect.orDie)

Hydration is the snapshot. Events sent after hydration are deltas that reducers apply locally.

Reduce events

Reducers live next to the client runtime. Client.reducer(...) narrows the event tag and returns the reducer unchanged at runtime.

import { Effect } from "effect"
 
import { TicTacToeClient } from "./TicTacToeClient.ts"
 
export const GameStarted = TicTacToeClient.reducer(
  "GameStarted",
  () =>
    ({ name }) =>
      Effect.succeed({ name, awaitingPartner: false }),
)
 
export const MoveMade = TicTacToeClient.reducer("MoveMade", () => (state) => Effect.succeed(state))
 
export const GameEnded = TicTacToeClient.reducer("GameEnded", () => (state) => Effect.succeed(state))

Each reducer receives the event first and the current state second. Return the next state to publish an update, or return void to leave the current state unchanged.

Provide reducers

Pass the reducer table to Client.layerSocket(...).

import { BrowserSocket } from "@effect/platform-browser"
import { Layer } from "effect"
import { Atom } from "effect/unstable/reactivity"
import { Client } from "liminal"
 
import { TicTacToeClient } from "./TicTacToeClient.ts"
import * as reducers from "./reducers.ts"
 
export const runtime = Atom.runtime(
  Client.layerSocket({
    client: TicTacToeClient,
    url: "/play",
    replay: { mode: "startup" },
    reducers,
  }).pipe(Layer.provide(BrowserSocket.layerWebSocketConstructor)),
)

Consume state

TicTacToeClient.state is a stream of hydrated and reduced state values.

import { runtime } from "./runtime"
import { TicTacToeClient } from "./TicTacToeClient.ts"
 
export const stateAtom = runtime.atom(TicTacToeClient.state)

Read Snapshot and Delta Events for event design guidance.