はじめに
「読み込み中...」のスピナーを消すために、どれだけの労力を払ってきたでしょうか。fetchでデータを取りに行き、ローディング状態を管理し、キャッシュを手動で無効化し、それでも画面には一瞬古いデータがちらつく。REST APIやGraphQLを積み重ねた末に行き着くのは、たいてい「同期処理の再発明」です。
Rocicorpが開発するZeroは、この問題そのものを構造から解決しようとする同期エンジンです。クライアントコードに書いたクエリを、そのままバックエンドのPostgresデータベースに対して実行できるようにし、結果はローカルキャッシュから即座に返しつつ、裏側で本物のデータと同期し続けます。掲げているキャッチコピーは「99% of Queries in Zero Milliseconds」。この記事では、Zeroがどういう仕組みでそれを実現しているのか、実際のコードとともに見ていきます。
Zeroとは
Zeroは、クライアントアプリケーションとPostgresデータベースの間に立つ同期エンジンです。ZQLと呼ばれるストリーミングクエリ言語でクエリを書くと、そのクエリはまずローカルのキャッシュに対して実行されて次のフレームで結果を返し、同時にサーバー側の正しい結果がバックグラウンドで同期されてきます。テーブル全体を同期するのではなく、「クエリ単位」でデータをクライアントに同期するのが最大の特徴です。
2年間の開発を経て、2026年にZero 1.0がリリースされ、APIの安定版としてコミットされました。それ以降の破壊的変更は限定的とされており、実運用に投入しやすい段階に入っています。
主な特徴
- 即時応答 -
zero-clientはミューテーションをまずローカルのデータに対して楽観的に適用してから、実際のPostgresへの書き込みをバックグラウンドで行います。体感速度はネットワーク往復を待たずに決まります - オフライン対応 - ローカルキャッシュにすでにあるデータであれば、ネットワークが切れていても読み書きが可能です
- リアルタイム同期 -
zero-cacheがクライアントとWebSocketで接続しており、Postgres側の変更(論理レプリケーション経由)を検知するとクライアントに即座に反映されます - 型安全なクエリ - ZQLはTypeScriptのスキーマ定義から型が導出されるため、存在しないカラムや型の不一致はコンパイル時に検出できます
- クエリ単位の同期 - テーブルまるごとではなく、
whereやrelatedで絞り込んだクエリの結果だけが同期対象になるので、無駄な転送を抑えられます
インストール
Zeroはzero-cacheというサーバープロセスを介して、論理レプリケーションを有効化したPostgres(v15以降)と接続する構成が前提になります。まずはPostgresを用意します。
docker run -d --name zero-postgres \
-e POSTGRES_DB="zero" \
-e POSTGRES_PASSWORD="pass" \
-p 5432:5432 \
postgres:18 \
postgres -c wal_level=logical
続いてクライアントパッケージをインストールします。
# npm
npm install @rocicorp/zero zod
# pnpm
pnpm add @rocicorp/zero zod
pnpm rebuild @rocicorp/zero-sqlite3
# bun
bun add @rocicorp/zero zod
bun pm trust @rocicorp/zero-sqlite3
.envにPostgresへの接続文字列を設定したうえで、開発用のzero-cacheを起動します。
ZERO_UPSTREAM_DB="postgres://postgres:pass@localhost:5432/zero" \
npx zero-cache-dev
なお、この構成上Zeroはブラウザ単体では完結しません。zero-cacheサーバーとPostgresが常時稼働している必要があるため、本記事ではブラウザだけで動く実行可能サンプルは掲載していません。
基本的な使い方
まずスキーマを定義します。ここで宣言したテーブル・カラムの型が、そのままZQLクエリの型として使われます。
// src/zero/schema.ts
import {boolean, createBuilder, createSchema, string, table} from '@rocicorp/zero'
const user = table('user')
.columns({
id: string(),
name: string(),
active: boolean(),
})
.primaryKey('id')
export const schema = createSchema({tables: [user]})
export const zql = createBuilder(schema)
ReactアプリではZeroProviderでアプリ全体をラップし、zero-cacheのURLとスキーマを渡します。
// src/app/providers.tsx
import {ZeroProvider} from '@rocicorp/zero/react'
import type {ZeroOptions} from '@rocicorp/zero'
import {schema} from '../zero/schema'
const opts: ZeroOptions = {
cacheURL: 'http://localhost:4848',
schema,
}
export function Providers({children}: {children: React.ReactNode}) {
return <ZeroProvider {...opts}>{children}</ZeroProvider>
}
クエリはdefineQueryで宣言し、コンポーネント側はuseQueryフックで購読します。Postgresのデータが更新されると、このフックの戻り値も自動で更新されます。
import {defineQueries, defineQuery} from '@rocicorp/zero'
import {useQuery} from '@rocicorp/zero/react'
import {zql} from '../zero/schema'
const queries = defineQueries({
activeUsers: defineQuery(() => zql.user.where('active', true)),
})
function UserList() {
const [users] = useQuery(queries.activeUsers())
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
)
}
where('active', true)のように、SQLに近い感覚でZQLのクエリを書けるのが分かるかと思います。
実践的なユースケース
リレーションを含むクエリの絞り込み
一覧画面では「関連するテーブルのデータも一緒に欲しい」という要求がよく出てきます。ZQLのrelatedを使うと、JOINに相当する処理をクエリチェーンの中で完結できます。以下は、特定アーティストのアルバムを新しい順に10件、アーティスト情報付きで取得する例です。
import {defineQueries, defineQuery} from '@rocicorp/zero'
import {z} from 'zod'
import {zql} from '../zero/schema'
const queries = defineQueries({
albums: {
byArtist: defineQuery(z.object({artistId: z.string()}), ({args: {artistId}}) =>
zql.albums
.where('artistId', artistId)
.orderBy('releaseYear', 'desc')
.limit(10)
.related('artist', (q) => q.one())
),
},
})
// コンポーネント側
const [albums] = useQuery(queries.albums.byArtist({artistId: 'artist_1'}))
where・orderBy・limit・relatedはすべてチェーン可能で、絞り込んだ結果だけがクライアントに同期されます。テーブル全体をローカルに持たせたくない場合に有効なパターンです。
mutatorsによる書き込みと楽観的更新
書き込み処理はdefineMutatorで宣言します。呼び出すとローカルのキャッシュには即座に反映され、Postgresへの実際の書き込みはバックグラウンドで進みます。ユーザーからすると「保存ボタンを押した瞬間に画面が更新される」体験になります。
import {defineMutators, defineMutator} from '@rocicorp/zero'
import {z} from 'zod'
const mutators = defineMutators({
albums: {
create: defineMutator(
z.object({
id: z.string(),
artistId: z.string(),
title: z.string(),
releaseYear: z.number(),
}),
async ({args, tx}) => {
await tx.mutate.albums.insert({...args, createdAt: Date.now()})
}
),
},
})
// コンポーネント側
const zero = useZero()
zero.mutate(
mutators.albums.create({
id: crypto.randomUUID(),
artistId: 'artist_1',
title: 'New Album',
releaseYear: 2026,
})
)
tx.mutate.albums.insertが実行するのはローカルの楽観的更新で、サーバー側の正当性チェックを通った結果が後から追いついてくる、という順序で処理が進みます。
コンテキストを使ったアクセス制御
ZeroにはRLS(行レベルセキュリティ)のような専用の権限システムはありません。代わりに、認証済みユーザーの情報をContextとしてクエリ・ミューテーターに渡し、where句で絞り込むことでアクセス制御を実装します。「見えてはいけない行はそもそも返さない」という設計です。
type ZeroContext = {id: string; role?: string}
// 自分が作成した投稿、または共有されている投稿だけを返す
const allowedPosts = defineQuery(({ctx}: {ctx: ZeroContext}) =>
zql.post.where(({cmp, exists, or}) =>
or(
cmp('authorID', ctx.id),
exists('sharedWith', (q) => q.where('userID', ctx.id))
)
)
)
// 更新できるのは投稿の作成者本人だけ
const updatePost = defineMutator(
z.object({id: z.string(), content: z.string()}),
async ({ctx, args: {id, content}, tx}: {ctx: ZeroContext; args: {id: string; content: string}; tx: any}) => {
const prev = await tx.run(zql.post.where('id', id).one())
if (prev?.authorID !== ctx.id) {
throw new Error('Access denied')
}
await tx.mutate.post.update({id, content})
}
)
このパターンでは、クエリとミューテーターの両方に同じContextを渡すことで、「誰が」「どのデータに」アクセスできるかを一箇所のロジックに集約できます。マルチテナントなアプリケーションを作る際の基本形になります。
まとめ
Zeroは、フロントエンドとPostgresの間に横たわっていた「フェッチしてローディング状態を管理する」という定型作業を、クエリ単位の同期という発想で置き換える同期エンジンです。ZQLによる型安全なクエリ、楽観的な書き込み、Contextベースのアクセス制御と、実運用を意識した機能が一通り揃っています。1.0のリリースによってAPIの安定性も担保されたので、次に作るアプリでローディングスピナーに悩まされたくない方は、一度zero-cacheを立ち上げて試してみる価値があります。
