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

Backend Config

defineBackendConfig() ties the schema, mutators, and actions together into a single typed object for the server. Actions access platform capabilities (AI, blob storage, permissions) through the platform caller via ctx.platform.call().

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. The callBot 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
import { defineBackendConfig } from "@synced-store/backend";
import { chatSchema } from "./schema";

export const chatBackendConfig = defineBackendConfig<typeof chatSchema>({
  schema: chatSchema,

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

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

TIP

defineBackendConfig() is optional — you can export a plain { schema, mutators, actions } object instead — but it provides full type inference, catching mismatches between your schema, mutators, and actions at compile time. See the bundled todo example for more details.

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.