Skip to content

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

MutationAction
Runs onClient + ServerServer only
Optimistic UIYes (instant)No (waits for server)
Use whenClient has all data neededNeeds 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.