Actors
This page is the API reference for actors.
If you want the conceptual background first, read Why Actors.
Actors are the core building block of Zocket. Each actor definition describes a stateful unit with a schema-validated state, typed methods, events, and lifecycle hooks.
Defining an Actor
Section titled “Defining an Actor”Use the actor() function from @zocket/core:
import { z } from "zod";import { actor } from "@zocket/core";
const Counter = actor({ state: z.object({ count: z.number().default(0), }),
methods: { increment: { handler: ({ state }) => { state.count += 1; return state.count; }, }, add: { input: z.object({ amount: z.number() }), handler: ({ state, input }) => { state.count += input.amount; return state.count; }, }, },});The returned ActorDef carries full type information — callers never need to specify generics manually.
State is defined using a Standard Schema (Zod, Valibot, etc.). The server initializes state by validating an empty object {} against your schema, so use .default() for fields:
state: z.object({ players: z.array(PlayerSchema).default([]), phase: z.enum(["lobby", "playing"]).default("lobby"), round: z.number().default(0),}),State is managed with Immer on the server. Inside method handlers, you mutate a draft directly — Zocket tracks changes and broadcasts JSON patches to subscribers.
Methods
Section titled “Methods”Each method has an optional input schema and a required handler:
methods: { // No input reset: { handler: ({ state }) => { state.count = 0; }, },
// With validated input setName: { input: z.object({ name: z.string().min(1) }), handler: ({ state, input }) => { state.name = input.name; }, },},MethodContext
Section titled “MethodContext”Every handler receives a context object:
| Property | Type | Description |
|---|---|---|
state | TState (Immer draft) | Mutable state — changes are tracked as patches |
input | InferSchema<TInput> | Validated input (or undefined if no schema) |
emit | TypedEmitFn | Emit typed events to subscribers |
connectionId | string | Opaque ID for the calling connection |
ctx | TCtx | Middleware context (see Middleware) |
Return Values
Section titled “Return Values”Methods can return values. The client receives the return value as a resolved promise:
// Serverhandler: ({ state }) => { return { count: state.count };},
// Clientconst result = await counter.increment(); // { count: 1 }Sequential Execution
Section titled “Sequential Execution”All method calls on a single actor instance are queued and executed sequentially — one at a time. This gives you single-writer semantics without locks.
Events
Section titled “Events”Events are typed messages broadcast to all connections subscribed to an actor instance:
const ChatRoom = actor({ state: z.object({ messages: z.array(MessageSchema).default([]), }),
methods: { send: { input: z.object({ text: z.string() }), handler: ({ state, input, emit, connectionId }) => { const msg = { text: input.text, from: connectionId }; state.messages.push(msg); emit("newMessage", msg); }, }, },
events: { newMessage: z.object({ text: z.string(), from: z.string() }), },});Event payloads are validated at runtime against their schemas before being broadcast.
Lifecycle Hooks
Section titled “Lifecycle Hooks”Actor Lifecycle
Section titled “Actor Lifecycle”Actors support onActivate and onDeactivate hooks. These fire when an actor instance is first created and before it is destroyed (eviction, shutdown, or redeploy):
const Room = actor({ state: z.object({ createdAt: z.number().default(0), }),
methods: { /* ... */ },
onActivate({ state }) { state.createdAt = Date.now(); },
onDeactivate({ state }) { console.log(`Room shutting down, was created at ${state.createdAt}`); },});Actor lifecycle hooks receive an ActorLifecycleContext with state (Immer draft). They are queued alongside method calls to preserve ordering.
Connection Lifecycle
Section titled “Connection Lifecycle”Actors also support onConnect and onDisconnect hooks. These fire when a connection first interacts with an actor instance and when it disconnects:
const Room = actor({ state: z.object({ online: z.array(z.string()).default([]), }),
methods: { /* ... */ },
onConnect({ state, connectionId }) { state.online.push(connectionId); },
onDisconnect({ state, connectionId }) { const idx = state.online.indexOf(connectionId); if (idx !== -1) state.online.splice(idx, 1); },});Connection lifecycle hooks receive a LifecycleContext with state (Immer draft), connectionId, and emit. They are queued alongside method calls to preserve ordering.
Note: middleware ctx is not available in lifecycle hooks — only in method handlers.
Full Example: Drawing Room
Section titled “Full Example: Drawing Room”From the example-draw package:
import { z } from "zod";import { actor, createApp } from "@zocket/core";
const Stroke = z.object({ points: z.array(z.tuple([z.number(), z.number()])), color: z.string(), width: z.number(),});
const DrawingRoom = actor({ state: z.object({ players: z.array(z.object({ id: z.string(), name: z.string(), score: z.number(), color: z.string(), connectionId: z.string().default(""), })).default([]), phase: z.enum(["lobby", "drawing", "roundEnd"]).default("lobby"), drawerId: z.string().default(""), word: z.string().default(""), hint: z.string().default(""), strokes: z.array(Stroke).default([]), round: z.number().default(0), }),
methods: { join: { input: z.object({ name: z.string() }), handler: ({ state, input, connectionId }) => { const existing = state.players.find((p) => p.name === input.name); if (existing) { existing.connectionId = connectionId; return { playerId: existing.id, color: existing.color }; } const id = Math.random().toString(36).slice(2, 10); const color = ["#ef4444", "#3b82f6", "#22c55e"][state.players.length % 3]; state.players.push({ id, name: input.name, score: 0, color, connectionId }); return { playerId: id, color }; }, },
draw: { input: z.object({ stroke: Stroke }), handler: ({ state, input }) => { state.strokes.push(input.stroke); }, },
guess: { input: z.object({ playerId: z.string(), text: z.string() }), handler: ({ state, input, emit }) => { const correct = input.text.toLowerCase() === state.word.toLowerCase(); if (correct) { emit("correctGuess", { name: input.playerId, word: state.word }); state.phase = "roundEnd"; } return { correct }; }, }, },
events: { correctGuess: z.object({ name: z.string(), word: z.string() }), },
onDisconnect({ state, connectionId }) { const idx = state.players.findIndex((p) => p.connectionId === connectionId); if (idx !== -1) state.players.splice(idx, 1); },});
export const app = createApp({ actors: { draw: DrawingRoom } });