Skip to content

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.

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.

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;
},
},
},

Every handler receives a context object:

PropertyTypeDescription
stateTState (Immer draft)Mutable state — changes are tracked as patches
inputInferSchema<TInput>Validated input (or undefined if no schema)
emitTypedEmitFnEmit typed events to subscribers
connectionIdstringOpaque ID for the calling connection
ctxTCtxMiddleware context (see Middleware)

Methods can return values. The client receives the return value as a resolved promise:

// Server
handler: ({ state }) => {
return { count: state.count };
},
// Client
const result = await counter.increment(); // { count: 1 }

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 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.

Actors 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);
},
});

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.

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 } });