Skip to main content

Overview

Zero provides a powerful, type-safe query API for fetching data from your schema. Queries are reactive - they automatically update when the underlying data changes.

Query Builder

Use createBuilder to create a query builder for your schema:
import { createBuilder } from '@rocicorp/zero';
import { schema } from './schema';

const zql = createBuilder(schema);
Create the query builder once at module level and reuse it throughout your application.

Basic Queries

Get All Records

// Get all users
const allUsers = zql.user;

// Materialize the query
const view = zero.materialize(allUsers);

Get Single Record

Use .one() to get a single record instead of an array:
// Returns a single user or undefined
const user = zql.user.where('id', 'user-123').one();
.one() returns undefined if no matching record is found. It throws an error if multiple records match.

Filtering

Simple Equality Filters

// Filter by exact match
const activeUsers = zql.user.where('status', 'active');

// Chain multiple filters (AND logic)
const adminUsers = zql.user
  .where('status', 'active')
  .where('role', 'admin');

Comparison Operators

// Greater than
const recentPosts = zql.post.where('created', '>', Date.now() - 86400000);

// Greater than or equal
const highPriority = zql.issue.where('priority', '>=', 5);

// Less than
const oldPosts = zql.post.where('created', '<', Date.now() - 86400000 * 7);

// Less than or equal
const lowPriority = zql.issue.where('priority', '<=', 2);

// Not equal
const nonDeletedUsers = zql.user.where('status', '!=', 'deleted');

String Matching

// LIKE pattern matching
const searchResults = zql.user.where('name', 'LIKE', '%alice%');

// Case-insensitive LIKE
const searchResults = zql.user.where('name', 'ILIKE', '%alice%');

// Escape special characters in user input
import { escapeLike } from '@rocicorp/zero';

const userInput = 'alice%';
const safePattern = `%${escapeLike(userInput)}%`;
const results = zql.user.where('name', 'LIKE', safePattern);

IN Queries

// Match any value in array
const specificUsers = zql.user.where('id', 'IN', ['user-1', 'user-2', 'user-3']);

// NOT IN
const excludedUsers = zql.user.where('status', 'NOT IN', ['deleted', 'banned']);

NULL Checks

// IS NULL
const unassignedIssues = zql.issue.where('assigneeID', 'IS', null);

// IS NOT NULL
const assignedIssues = zql.issue.where('assigneeID', 'IS NOT', null);

Sorting

Order By Single Column

// Ascending order
const usersByName = zql.user.orderBy('name', 'asc');

// Descending order
const recentPosts = zql.post.orderBy('created', 'desc');

Order By Multiple Columns

// First by status, then by name
const sortedIssues = zql.issue
  .orderBy('status', 'asc')
  .orderBy('name', 'asc');

Limiting Results

// Get first 10 records
const topUsers = zql.user.limit(10);

// Get first 5 recent posts
const recentPosts = zql.post
  .orderBy('created', 'desc')
  .limit(5);
.limit() is applied after filtering and sorting.

Relationships

Query across relationships defined in your schema:
import { relationships } from '@rocicorp/zero';

// Define relationships
const userRelationships = relationships(user, ({ many }) => ({
  posts: many({
    sourceField: ['id'],
    destField: ['authorID'],
    destSchema: post,
  }),
}));

const postRelationships = relationships(post, ({ one }) => ({
  author: one({
    sourceField: ['authorID'],
    destField: ['id'],
    destSchema: user,
  }),
}));

// Query with relationships
const postsWithAuthor = zql.post.related('author');

// Query user's posts
const userWithPosts = zql.user
  .where('id', 'user-123')
  .one()
  .related('posts');

Advanced Queries

Combining Conditions

// Multiple filters create AND conditions
const query = zql.issue
  .where('status', 'open')
  .where('priority', '>=', 5)
  .where('assigneeID', 'IS NOT', null);

// This is equivalent to:
// WHERE status = 'open' AND priority >= 5 AND assigneeID IS NOT NULL

Complex Filters

// Search across multiple fields
const searchUsers = (term: string) => {
  const pattern = `%${escapeLike(term)}%`;
  return zql.user.where('name', 'ILIKE', pattern);
};

// Date range queries
const postsInRange = zql.post
  .where('created', '>=', startDate)
  .where('created', '<=', endDate);

Custom Queries

For complex queries not supported by the query builder, define custom queries:
import { syncedQuery } from '@rocicorp/zero';

// Define a custom query function
const searchIssues = syncedQuery(
  schema,
  async (tx, { projectID, term }: { projectID: string; term: string }) => {
    // Use tx.run() with query builder for type safety
    const issues = await tx.run(
      zql.issue
        .where('projectID', projectID)
        .where('title', 'ILIKE', `%${term}%`)
    );
    return issues;
  }
);

// Register custom queries
import { defineQueries } from '@rocicorp/zero';

const queries = defineQueries(schema, {
  searchIssues,
});

// Use in Zero client
const zero = new Zero({
  schema,
  server: 'https://your-zero-server.com',
  userID: 'user-123',
});

// Materialize custom query
const view = zero.materialize(
  queries.searchIssues({ projectID: 'proj-1', term: 'bug' })
);

Query Result Types

Queries return data with a result type indicating the state:

Result Type Values

  • unknown - Query is loading, data may be partial or stale
  • complete - Query has fully loaded from server
  • error - Query encountered an error
const view = zero.materialize(zql.user);

view.addListener((data, resultType, error) => {
  switch (resultType) {
    case 'unknown':
      console.log('Loading...', data); // May have partial data
      break;
    case 'complete':
      console.log('Complete:', data); // Full data from server
      break;
    case 'error':
      console.error('Error:', error); // Query failed
      break;
  }
});

Query Caching with TTL

Control how long queries remain active after the last subscriber:
// Keep query alive forever
const view = zero.materialize(query, { ttl: 'forever' });

// Destroy immediately when no subscribers
const view = zero.materialize(query, { ttl: 'never' });

// Keep alive for 5 minutes (in seconds)
const view = zero.materialize(query, { ttl: '5m' });

// Keep alive for 30 seconds
const view = zero.materialize(query, { ttl: '30s' });

// Keep alive for 60 seconds (in milliseconds)
const view = zero.materialize(query, { ttl: 60_000 });
Use longer TTL values for frequently accessed queries to improve performance and reduce server load.

Preloading Queries

Preload queries before they’re needed:
// Start loading a query early
const view = zero.materialize(zql.user.where('id', 'user-123').one(), {
  ttl: '1m',
});

// Later, when you need the data, it's already loaded
// The view is reused if the query hash matches

Query Performance

Indexing

Zero automatically creates indexes for primary keys and foreign keys. For best performance:
  • Filter by indexed columns when possible
  • Add indexes on frequently queried columns
  • Use compound indexes for multi-column filters

Pagination

For large datasets, use pagination:
const PAGE_SIZE = 50;

// Page 1
const page1 = zql.user
  .orderBy('created', 'desc')
  .limit(PAGE_SIZE);

// Page 2 (in practice, you'd track the last ID)
const page2 = zql.user
  .where('created', '<', lastCreated)
  .orderBy('created', 'desc')
  .limit(PAGE_SIZE);

Virtual Scrolling

For very large lists, consider using virtual scrolling:
import { useZeroVirtualizer } from '@rocicorp/zero-virtual/react';

function UserList() {
  const virtualizer = useZeroVirtualizer({
    query: zql.user.orderBy('name', 'asc'),
    estimateSize: () => 50, // Estimated row height
  });

  return (
    <div style={{ height: '600px', overflow: 'auto' }}>
      {virtualizer.getVirtualItems().map(item => (
        <div key={item.key} data-index={item.index}>
          {item.data.name}
        </div>
      ))}
    </div>
  );
}

TypeScript Types

Queries are fully typed based on your schema:
const schema = createSchema({
  tables: [
    table('user').columns({
      id: string(),
      name: string(),
      age: number(),
    }).primaryKey('id'),
  ],
});

const zql = createBuilder(schema);

// TypeScript knows the return type
const users = zql.user; // Query<'user', Schema, User[]>
const user = zql.user.one(); // Query<'user', Schema, User | undefined>

// Filters are type-checked
zql.user.where('name', 'Alice'); // ✓ Valid
zql.user.where('invalidColumn', 'value'); // ✗ Type error
zql.user.where('age', 'not-a-number'); // ✗ Type error

Next Steps

Mutations

Learn how to modify data with mutations

React Integration

Use queries in React with useQuery

Build docs developers (and LLMs) love