Skip to main content

Overview

Mutations in Zero provide optimistic updates, automatic rollback on errors, and server confirmation. All mutations are applied locally first, then synchronized with the server.

CRUD Operations

Zero provides built-in CRUD (Create, Read, Update, Delete) operations for all tables in your schema.
CRUD operations are legacy and require enableLegacyMutators: true in your schema. We recommend using custom mutators instead.

Insert

Create a new record:
const result = zero.mutate.user.insert({
  id: 'user-123',
  name: 'Alice',
  email: '[email protected]',
  status: 'active',
});

// Wait for client confirmation (optimistic)
await result.client;

// Wait for server confirmation
await result.server;

Update

Update an existing record (requires primary key):
// Update specific fields
await zero.mutate.user.update({
  id: 'user-123',
  name: 'Alice Smith',
  email: '[email protected]',
});
update() requires the primary key and will fail if the record doesn’t exist.

Upsert

Insert or update a record:
// Insert if doesn't exist, update if exists
await zero.mutate.user.upsert({
  id: 'user-123',
  name: 'Alice',
  email: '[email protected]',
  status: 'active',
});

Delete

Delete a record by primary key:
// Delete by ID
await zero.mutate.user.delete('user-123');

// Delete by compound key
await zero.mutate.issueLabel.delete({
  issueID: 'issue-1',
  labelID: 'label-1',
});

Batch Mutations

Execute multiple mutations atomically:
await zero.mutateBatch(async (m) => {
  // All operations in this batch are applied together
  await m.user.insert({
    id: 'user-1',
    name: 'Alice',
    email: '[email protected]',
  });
  
  await m.post.insert({
    id: 'post-1',
    authorID: 'user-1',
    title: 'Hello World',
    content: 'My first post',
  });
  
  await m.post.insert({
    id: 'post-2',
    authorID: 'user-1',
    title: 'Second Post',
    content: 'Another post',
  });
});
Batch mutations are more efficient than individual mutations and ensure atomicity - either all operations succeed or all fail.

Custom Mutators

Custom mutators let you define business logic and complex operations.

Defining Custom Mutators

import { defineMutators } from '@rocicorp/zero';
import { schema } from './schema';

const mutators = defineMutators(schema, {
  // Simple mutator
  incrementPostViews: async (tx, args: { postID: string }) => {
    const post = await tx.run(
      zql.post.where('id', args.postID).one()
    );
    
    if (post) {
      await tx.mutate.post.update({
        id: args.postID,
        views: post.views + 1,
      });
    }
  },
  
  // Complex mutator with multiple operations
  createIssueWithLabels: async (
    tx,
    args: { title: string; projectID: string; labelIDs: string[] }
  ) => {
    const issueID = generateID();
    
    // Create issue
    await tx.mutate.issue.insert({
      id: issueID,
      title: args.title,
      projectID: args.projectID,
      status: 'open',
      created: Date.now(),
    });
    
    // Attach labels
    for (const labelID of args.labelIDs) {
      await tx.mutate.issueLabel.insert({
        issueID,
        labelID,
        projectID: args.projectID,
      });
    }
  },
});

Using Custom Mutators

Pass mutators to the Zero client:
const zero = new Zero({
  schema,
  server: 'https://your-zero-server.com',
  userID: 'user-123',
  mutators, // Add your custom mutators
});

// Use custom mutators
const result = zero.mutate.incrementPostViews({ postID: 'post-1' });

await zero.mutate.createIssueWithLabels({
  title: 'New Issue',
  projectID: 'proj-1',
  labelIDs: ['label-1', 'label-2'],
});

Nested Mutators

Organize mutators into namespaces:
const mutators = defineMutators(schema, {
  post: {
    publish: async (tx, args: { postID: string }) => {
      await tx.mutate.post.update({
        id: args.postID,
        status: 'published',
        publishedAt: Date.now(),
      });
    },
    
    unpublish: async (tx, args: { postID: string }) => {
      await tx.mutate.post.update({
        id: args.postID,
        status: 'draft',
        publishedAt: null,
      });
    },
  },
  
  user: {
    ban: async (tx, args: { userID: string; reason: string }) => {
      await tx.mutate.user.update({
        id: args.userID,
        status: 'banned',
      });
      
      // Log ban action
      await tx.mutate.auditLog.insert({
        id: generateID(),
        action: 'ban',
        targetUserID: args.userID,
        reason: args.reason,
        timestamp: Date.now(),
      });
    },
  },
});

// Use namespaced mutators
await zero.mutate.post.publish({ postID: 'post-1' });
await zero.mutate.user.ban({ userID: 'user-123', reason: 'spam' });

Transaction Context

Custom mutators receive a transaction context with:

Query Data

const mutators = defineMutators(schema, {
  transferOwnership: async (
    tx,
    args: { projectID: string; newOwnerID: string }
  ) => {
    // Query data within transaction
    const project = await tx.run(
      zql.project.where('id', args.projectID).one()
    );
    
    if (!project) {
      throw new Error('Project not found');
    }
    
    await tx.mutate.project.update({
      id: args.projectID,
      ownerID: args.newOwnerID,
    });
  },
});

Client Information

const mutators = defineMutators(schema, {
  createPost: async (tx, args: { title: string; content: string }) => {
    await tx.mutate.post.insert({
      id: generateID(),
      title: args.title,
      content: args.content,
      clientID: tx.clientID, // Current client ID
      mutationID: tx.mutationID, // Current mutation ID
    });
  },
});

Location and Reason

const mutators = defineMutators(schema, {
  trackMutation: async (tx, args: { action: string }) => {
    console.log('Running on:', tx.location); // 'client' or 'server'
    console.log('Reason:', tx.reason); // 'optimistic' or 'rebase'
    
    if (tx.location === 'client' && tx.reason === 'optimistic') {
      // This is the initial optimistic mutation
    }
  },
});

Mutation Results

All mutations return a MutatorResult with two promises:
const result = zero.mutate.user.insert({
  id: 'user-123',
  name: 'Alice',
});

// Client promise resolves when mutation is applied locally
const clientResult = await result.client;
if (clientResult.type === 'success') {
  console.log('Applied locally');
} else {
  console.error('Local error:', clientResult.error);
}

// Server promise resolves when server confirms
const serverResult = await result.server;
if (serverResult.type === 'success') {
  console.log('Confirmed by server');
} else {
  console.error('Server error:', serverResult.error);
}

Error Handling

try {
  const result = zero.mutate.user.update({
    id: 'user-123',
    name: 'Alice',
  });
  
  await result.server;
} catch (error) {
  // Handle errors
  console.error('Mutation failed:', error);
}

// Or handle with result
const result = zero.mutate.user.update({
  id: 'user-123',
  name: 'Alice',
});

const serverResult = await result.server;
if (serverResult.type === 'error') {
  if (serverResult.error.type === 'app') {
    // Application error from server
    console.error('App error:', serverResult.error.message);
    console.error('Details:', serverResult.error.details);
  } else {
    // Zero infrastructure error
    console.error('Zero error:', serverResult.error.message);
  }
}

Optimistic Updates

All mutations are optimistic by default:
// User sees the change immediately
await zero.mutate.user.update({
  id: 'user-123',
  name: 'Alice',
});

// UI updates immediately with new name
const [user] = useQuery(zql.user.where('id', 'user-123').one());
console.log(user.name); // 'Alice' (optimistic)

// If server rejects, change is automatically rolled back
Optimistic updates make your UI feel instant. Zero handles rollback automatically if the server rejects the mutation.

Server-Side Mutations

Mutators run both on the client (optimistically) and on the server (authoritatively):
// Define mutator (runs on both client and server)
const mutators = defineMutators(schema, {
  createPost: async (tx, args: { title: string; content: string }, ctx) => {
    // ctx is passed from Zero options
    const userID = ctx.sub;
    
    await tx.mutate.post.insert({
      id: generateID(),
      authorID: userID,
      title: args.title,
      content: args.content,
      created: Date.now(),
    });
  },
});

// Pass context to Zero client
const zero = new Zero({
  schema,
  server: 'https://your-zero-server.com',
  userID: 'user-123',
  mutators,
  context: { sub: 'user-123', role: 'admin' }, // Available as ctx parameter
});

MutateRequest Pattern

Call mutators dynamically using MutateRequest:
import { defineMutator } from '@rocicorp/zero';

// Define individual mutators
const incrementViews = defineMutator(schema, async (tx, args: { postID: string }) => {
  // ...
});

const deletePost = defineMutator(schema, async (tx, args: { postID: string }) => {
  // ...
});

// Create mutation request
const request = incrementViews({ postID: 'post-1' });

// Execute dynamically
await zero.mutate(request);

Context and Authentication

Access user context in mutations:
type Context = {
  sub: string;
  role: 'admin' | 'user';
};

const mutators = defineMutators(schema, {
  deletePost: async (
    tx,
    args: { postID: string },
    ctx: Context
  ) => {
    const post = await tx.run(
      zql.post.where('id', args.postID).one()
    );
    
    if (!post) {
      throw new Error('Post not found');
    }
    
    // Check permissions
    if (post.authorID !== ctx.sub && ctx.role !== 'admin') {
      throw new Error('Not authorized');
    }
    
    await tx.mutate.post.delete(args.postID);
  },
});

Best Practices

Validate on Server: Always validate mutations on the server, not just the client. Client-side validation can be bypassed.
Keep Mutations Pure: Mutators should be deterministic - given the same inputs, they should produce the same outputs.
Use Batch for Multiple Operations: Group related mutations in a batch to ensure atomicity.
Don’t Use External APIs: Mutators run on both client and server. Avoid calling external APIs inside mutators.
Avoid Side Effects: Don’t generate random IDs, use timestamps, or perform other non-deterministic operations inside mutators. Do this before calling the mutator.

TypeScript Types

Mutations are fully typed:
const mutators = defineMutators(schema, {
  createUser: async (
    tx,
    args: { name: string; email: string } // Args are strongly typed
  ) => {
    await tx.mutate.user.insert({
      id: generateID(),
      name: args.name, // TypeScript knows these fields
      email: args.email,
      status: 'active',
    });
  },
});

// Usage is type-checked
await zero.mutate.createUser({
  name: 'Alice',
  email: '[email protected]',
}); // ✓ Valid

await zero.mutate.createUser({
  name: 'Alice',
}); // ✗ Type error: missing email

await zero.mutate.createUser({
  name: 'Alice',
  email: '[email protected]',
  invalid: true,
}); // ✗ Type error: invalid property

Next Steps

React Integration

Use mutations in React components

Server Setup

Set up the server to process mutations

Build docs developers (and LLMs) love