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 aMutatorResult 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 usingMutateRequest:
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