Appearance
Mutators
Mutators are functions that modify store data. They run on both the client (for instant optimistic updates) and the server (to create canonical state). The same mutation code runs in both environments.
Defining Mutators
First, declare mutators in your schema:
typescript
import { z } from "zod";
import { defineSchema, table } from "@synced-store/shared";
const schema = defineSchema({
schemaVersion: 1,
tables: {
todos: { schema: table(z.object({ title: z.string() })) },
},
mutators: {
addTodo: {
description: "Add a new todo",
input: z.object({ id: z.string(), title: z.string() }),
},
},
});Then implement the handlers:
typescript
const mutators = {
addTodo: async (ctx, input) => {
await ctx.table("todos").set(input.id, { title: input.title });
},
};Best Practices
Because mutations are executed in multiple places — first optimistically on the client, then again on the server — certain patterns can lead to unexpected behavior. Following these guidelines ensures your app behaves predictably.
Use Explicit Values Instead of Toggles
Mutations that depend on current state (like toggles) can produce surprising results when replayed during a rebase.
typescript
// BAD: State-dependent mutation
const toggleIsDone = async (ctx, { id }) => {
const todo = await ctx.table("todos").get(id);
await ctx.table("todos").set({
itemKey: id,
value: { ...todo, done: !todo.done },
});
};Consider this scenario: A user sees an unchecked checkbox and clicks to mark it done. Meanwhile, another user marks the same item done. When the first user's mutation is replayed during rebase, it toggles the already-done item back to not done — the opposite of what they intended.
User A Server User B
done = false
Clicks toggle
Optimistic: done = true
Clicks toggle
Processes B's toggle
done = true
Poke: done = true
Rebase:
1. Server: done = true
2. Replay toggle()
3. Result: done = false <-- Wrong!Instead, pass the intended value explicitly:
typescript
// GOOD: Explicit value
const setTodoDone = async (ctx, { id, done }) => {
const todo = await ctx.table("todos").get(id);
await ctx.table("todos").set({ itemKey: id, value: { ...todo, done } });
};Now the user's intent ("mark this done") is preserved regardless of what state changes occurred in between.
Generate IDs Outside Mutations
Since mutations run on both client and server, generating IDs inside a mutation produces different IDs on each execution:
typescript
// BAD: Generates ID inside mutation
const addTodo = async (ctx, { text }) => {
const id = Math.random().toString(); // Different ID each time!
await ctx.table("todos").set({ itemKey: id, value: { text, done: false } });
return id;
};When the mutation runs on the server, it creates a different ID than the client did — making it impossible to reference the same todo in subsequent mutations.
typescript
// GOOD: Pass ID as parameter
const addTodo = async (ctx, { id, text }) => {
await ctx.table("todos").set({ itemKey: id, value: { text, done: false } });
};
// Usage: Generate ID outside mutation
const id = crypto.randomUUID();
await store.mutate.addTodo({ id, text: "Buy milk" });
await store.mutate.setTodoDone({ id, done: true });By generating the ID outside the mutation, the server-side mutation is guaranteed to operate on the same ID as the client-side one.
Read Current State
Always read current state before modifying. Don't assume what state looks like:
typescript
setTodo: async (ctx, input) => {
const existing = await ctx.table("items").get(input.id);
const todo = {
id: input.id,
text: input.text ?? existing?.text ?? "",
completed: input.completed ?? existing?.completed ?? false,
createdAt: existing?.createdAt ?? Date.now(),
updatedAt: Date.now(),
};
await ctx.table("items").set(input.id, todo);
},Forking Logic with ctx.isServer
Since mutators run on both client and server, you can use ctx.isServer to fork behavior. A common use is marking data as pending on the client (not yet confirmed by the server) so the UI can show a visual indicator:
typescript
const mutators = {
sendMessage: async (ctx, input) => {
const isPending = !ctx.isServer;
await ctx.table("messages").set(input.id, {
id: input.id,
text: input.text,
senderId: ctx.userId,
isPending,
});
},
};On the client, isPending is true — the UI can render the message with a spinner or reduced opacity. When the server replays the mutation, isPending is false, and that confirmed value syncs back to the client, removing the pending indicator.
Reading Data with Queries
While mutations modify data, queries let you read from the local store:
typescript
// One-time read
const todos = await store.query((ctx) =>
ctx.table("todos").entries().toArray()
);
// Reactive subscription - callback runs whenever todos change
store.subscribe(
async (ctx) => await ctx.table("todos").entries().toArray(),
(todos) => updateTodoList(todos),
);Queries always read from the local store, which includes both server-confirmed data and pending optimistic updates.
Calling the Client
typescript
// Mutation returns immediately with a confirmation promise
const { id, confirmed } = await store.mutate.addTodo({
id: crypto.randomUUID(),
text: "Buy groceries",
});
// UI is already updated. Optionally wait for server confirmation:
await confirmed;