Appearance
Actions
Actions are server-only operations. Use them for long-ish running processes that may trigger many mutations — like calling an LLM and streaming the response back as a series of updates.
When to Use Actions vs Mutators
| Mutation | Action | |
|---|---|---|
| Runs on | Client + Server | Server only |
| Optimistic UI | Yes (instant) | No (waits for server) |
| Use when | Client has all data needed | Needs server-only data or randomness |
Example: Chat with Bot
A chat app where users can mention a bot (e.g. @Claude tell me a story) and get a streamed LLM response. This shows the core actions pattern: a mutation for the instant client update, and an action for the long-running server work that triggers many mutations as results stream in.
Schema
typescript
const chatSchema = defineSchema({
schemaVersion: 1,
tables: {
messages: {
schema: table(
z.object({
id: z.string(),
text: z.string(),
role: z.enum(["user", "assistant"]),
senderId: z.string(),
status: z.enum(["incomplete", "complete", "error"]).optional(),
}),
),
},
},
mutators: {
sendMessage: {
description: "Send a message, optionally triggering a bot response",
input: z.object({
id: z.string(),
text: z.string(),
botMessageId: z.string().optional(),
}),
},
updateMessage: {
description: "Update a message's text and status",
input: z.object({
id: z.string(),
text: z.string(),
status: z.enum(["incomplete", "complete", "error"]),
}),
},
},
actions: {
callBot: {
description: "Stream an LLM response into a placeholder message",
input: z.object({
botName: z.string(),
botMessageId: z.string(),
}),
output: z.object({
success: z.boolean(),
response: z.string().optional(),
}),
},
},
});Mutators
The sendMessage mutator saves the user's message and — if a bot is mentioned — creates a placeholder and enqueues the action. updateMessage is a simple mutator the action calls repeatedly to stream text in.
typescript
const mutators = {
sendMessage: async (ctx, input) => {
// 1. Save the user's message
await ctx.table("messages").set(input.id, {
id: input.id,
text: input.text,
role: "user",
senderId: ctx.userId,
});
// 2. Check for a bot mention like "@Claude tell me a story"
const mentionMatch = input.text.match(/^@(\S+)\s/);
if (mentionMatch && input.botMessageId) {
const botName = mentionMatch[1];
// 3. Create a placeholder message for the bot response (instant on client)
await ctx.table("messages").set(input.botMessageId, {
id: input.botMessageId,
text: "",
role: "assistant",
senderId: botName,
status: "incomplete",
});
// 4. Enqueue the server-side action (no-op on client)
ctx.enqueueAction("callBot", {
botName,
botMessageId: input.botMessageId,
});
}
},
updateMessage: async (ctx, input) => {
await ctx.table("messages").set(input.id, {
...(await ctx.table("messages").get(input.id)),
text: input.text,
status: input.status,
});
},
};Action (server-only)
The action calls ctx.mutate("updateMessage", ...) in a loop — each call is a mutation that syncs to all connected clients, so the bot's response streams in chunk by chunk.
typescript
const actions = {
callBot: async (ctx, input) => {
let fullResponse = "";
for await (const event of callBotApi({ botName: input.botName })) {
fullResponse += event.text;
// Each ctx.mutate() triggers a real mutation that syncs to all clients
await ctx.mutate("updateMessage", {
id: input.botMessageId,
text: fullResponse,
status: "incomplete",
});
}
// Final update marks the message as complete
await ctx.mutate("updateMessage", {
id: input.botMessageId,
text: fullResponse,
status: "complete",
});
return { success: true, response: fullResponse };
},
};Client Usage
typescript
// Mutation: instant optimistic update — user sees their message + placeholder immediately
await store.mutate.sendMessage({
id: "msg-1",
text: "@Claude tell me a story",
botMessageId: "msg-2",
});
// The bot's response streams in automatically via synced-store updates.
// No polling or manual fetching needed — just render the messages table.The user sees the placeholder message appear instantly (optimistic update), and the bot's response streams in as the server-side action calls updateMessage repeatedly.
Enqueuing Actions from Mutators
Mutators can trigger server-side actions as a side effect using ctx.enqueueAction(). This is a no-op on the client — the action only runs on the server after the mutation commits. See the sendMessage mutator above for an example.