Skip to main content

Overview

Zero provides React hooks for seamless integration with React applications. The hooks handle subscription management, automatic updates, and proper cleanup.

Installation

npm install @rocicorp/zero

Setup

ZeroProvider

Wrap your app with ZeroProvider to make Zero available to all components:
import { ZeroProvider } from '@rocicorp/zero/react';
import { schema } from './schema';

function App() {
  return (
    <ZeroProvider
      schema={schema}
      server="https://your-zero-server.com"
      userID="user-123"
    >
      <YourApp />
    </ZeroProvider>
  );
}

Provider Props

The ZeroProvider accepts the same props as the Zero constructor:
<ZeroProvider
  schema={schema}
  server="https://your-zero-server.com"
  userID="user-123"
  auth="your-auth-token"
  mutators={mutators}
  context={{ sub: 'user-123', role: 'admin' }}
  logLevel="info"
  onOnlineChange={(online) => console.log('Online:', online)}
>
  <YourApp />
</ZeroProvider>

Dynamic Authentication

Update auth token without recreating the Zero client:
function App() {
  const [authToken, setAuthToken] = useState<string | undefined>();

  return (
    <ZeroProvider
      schema={schema}
      server="https://your-zero-server.com"
      userID="user-123"
      auth={authToken} // Provider handles auth changes
    >
      <LoginHandler onToken={setAuthToken} />
      <YourApp />
    </ZeroProvider>
  );
}

External Zero Instance

Pass an existing Zero instance instead of creating one:
import { Zero } from '@rocicorp/zero';

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

function App() {
  return (
    <ZeroProvider zero={zero}>
      <YourApp />
    </ZeroProvider>
  );
}

useQuery

The useQuery hook subscribes to a query and returns reactive data:
import { useQuery } from '@rocicorp/zero/react';
import { createBuilder } from '@rocicorp/zero';
import { schema } from './schema';

const zql = createBuilder(schema);

function UserList() {
  const [users, resultDetails] = useQuery(zql.user);

  if (resultDetails.type === 'unknown') {
    return <div>Loading...</div>;
  }

  if (resultDetails.type === 'error') {
    return <div>Error: {resultDetails.error.message}</div>;
  }

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Query Result

useQuery returns a tuple:
const [data, details] = useQuery(query);
  • data: The query result (array or single value)
  • details: Query metadata with type property:
    • unknown - Loading, data may be partial
    • complete - Fully loaded from server
    • error - Query failed

Conditional Queries

Pass falsy values to disable queries:
function UserProfile({ userID }: { userID: string | null }) {
  // Query is disabled when userID is null
  const [user, details] = useQuery(
    userID ? zql.user.where('id', userID).one() : null
  );

  if (!userID) {
    return <div>Select a user</div>;
  }

  if (details.type === 'unknown') {
    return <div>Loading...</div>;
  }

  return <div>{user?.name ?? 'Not found'}</div>;
}

Query Options

// Disable query based on condition
const [users] = useQuery(query, { enabled: isLoggedIn });

// Or use boolean shorthand
const [users] = useQuery(query, isLoggedIn);

// Set TTL (time to live)
const [users] = useQuery(query, { 
  enabled: true,
  ttl: '5m', // Keep alive for 5 minutes
});

useSuspenseQuery

For React Suspense integration:
import { Suspense } from 'react';
import { useSuspenseQuery } from '@rocicorp/zero/react';

function UserList() {
  // Suspends until data is ready
  const [users] = useSuspenseQuery(zql.user);

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <UserList />
    </Suspense>
  );
}

Suspend Until Options

// Suspend until partial data (default)
const [users] = useSuspenseQuery(query, { 
  suspendUntil: 'partial' 
});

// Suspend until complete data from server
const [users] = useSuspenseQuery(query, { 
  suspendUntil: 'complete' 
});

useZero

Access the Zero instance directly:
import { useZero } from '@rocicorp/zero/react';

function MyComponent() {
  const zero = useZero();

  const handleMutation = async () => {
    await zero.mutate.user.insert({
      id: 'user-123',
      name: 'Alice',
    });
  };

  return <button onClick={handleMutation}>Create User</button>;
}

useConnectionState

Monitor the connection status:
import { useConnectionState } from '@rocicorp/zero/react';
import { ConnectionStatus } from '@rocicorp/zero';

function ConnectionIndicator() {
  const connectionState = useConnectionState();

  return (
    <div>
      Status: {connectionState.status}
      {connectionState.status === ConnectionStatus.Connected && (
        <span>Connected at {new Date(connectionState.connectedAt).toLocaleTimeString()}</span>
      )}
      {connectionState.status === ConnectionStatus.Error && (
        <span>Error: {connectionState.error?.message}</span>
      )}
    </div>
  );
}

Common Patterns

Loading States

function UserList() {
  const [users, { type }] = useQuery(zql.user);

  return (
    <div>
      {type === 'unknown' && <LoadingSpinner />}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Error Handling

function UserList() {
  const [users, details] = useQuery(zql.user);

  if (details.type === 'error') {
    return (
      <div>
        <p>Error: {details.error.message}</p>
        <button onClick={details.retry}>Retry</button>
      </div>
    );
  }

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Dynamic Queries

function FilteredUserList() {
  const [status, setStatus] = useState<'active' | 'inactive'>('active');
  const [users] = useQuery(zql.user.where('status', status));

  return (
    <div>
      <select value={status} onChange={e => setStatus(e.target.value)}>
        <option value="active">Active</option>
        <option value="inactive">Inactive</option>
      </select>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Mutations

function CreateUserForm() {
  const zero = useZero();
  const [name, setName] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      const result = zero.mutate.user.insert({
        id: crypto.randomUUID(),
        name,
        created: Date.now(),
      });

      // Wait for server confirmation
      await result.server;
      
      setName('');
    } catch (error) {
      console.error('Failed to create user:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={e => setName(e.target.value)}
        disabled={isSubmitting}
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

Custom Mutations

function PublishPostButton({ postID }: { postID: string }) {
  const zero = useZero();
  const [isPublishing, setIsPublishing] = useState(false);

  const handlePublish = async () => {
    setIsPublishing(true);
    try {
      const result = zero.mutate.post.publish({ postID });
      await result.server;
    } catch (error) {
      console.error('Failed to publish:', error);
    } finally {
      setIsPublishing(false);
    }
  };

  return (
    <button onClick={handlePublish} disabled={isPublishing}>
      {isPublishing ? 'Publishing...' : 'Publish'}
    </button>
  );
}

Pagination

function PaginatedUserList() {
  const [page, setPage] = useState(0);
  const PAGE_SIZE = 20;

  const [users, { type }] = useQuery(
    zql.user
      .orderBy('created', 'desc')
      .limit(PAGE_SIZE)
      // In practice, you'd implement cursor-based pagination
  );

  return (
    <div>
      {type === 'unknown' && <div>Loading...</div>}
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      <button onClick={() => setPage(p => p - 1)} disabled={page === 0}>
        Previous
      </button>
      <button onClick={() => setPage(p => p + 1)}>
        Next
      </button>
    </div>
  );
}

TypeScript

Type-Safe Hooks

Define default types for automatic inference:
// types.ts
import { schema } from './schema';
import type { Context } from './auth';

declare module '@rocicorp/zero' {
  interface DefaultTypes {
    schema: typeof schema;
    context: Context;
  }
}
// Now hooks are fully typed automatically
function UserList() {
  const [users] = useQuery(zql.user);
  // users is typed as User[]
  
  const zero = useZero();
  // zero.mutate is typed with your mutators
}

Best Practices

Create Query Builder Once: Define zql at module level, not inside components.
Use Suspense for Simple Cases: useSuspenseQuery simplifies loading states in many cases.
Handle Errors Gracefully: Always check for error state and provide retry functionality.
Don’t Create Zero in Render: Use ZeroProvider or useMemo to avoid recreating the Zero instance.

Next Steps

Solid Integration

Use Zero with Solid.js

React Native

Use Zero in React Native apps

Build docs developers (and LLMs) love