Quickstart

Set up a shared schema, a client, and a server endpoint. The server example uses application-owned authentication and database functions that you will need to implement.

Install

Install the package and its peer dependencies:

pnpm add valtio-sync valtio zod

Define the shared schema

Save these definitions in schema.ts. The account holds one record; a collection holds records identified by an id.

import { defineAccount, defineCollection } from 'valtio-sync/schema'
import { z } from 'zod'

export const account = defineAccount({
  fields: {
    theme: z.enum(['light', 'dark']).default('light'),
  },
})

export const todos = defineCollection({
  fields: {
    id: z.string(),
    title: z.string().default(''),
    completed: z.boolean().default(false),
  },
})

Create and hydrate the client

Create the client near your app shell after authentication. In this example, user.id is the signed-in user's stable ID. It keeps each user's local cache separate.

import { valtioSync } from 'valtio-sync/client'
import { account, todos } from './schema'
import { z } from 'zod'

export const sync = valtioSync({
  endpoint: '/api/sync',
  schema: { account, todos },
  storage: { namespace: `my-app:${user.id}` },
  device: {
    deviceId: z.string().default(() => crypto.randomUUID()),
  },
  session: {
    sidebarOpen: z.boolean().default(false),
  },
})

await sync.hydrate()

Wait for hydrate() before allowing edits. It loads the saved local state.

Edit and sync state

Mutate the returned Valtio proxies directly. Before running sync(), set up the /api/sync endpoint shown in the next section.

sync.account.theme = 'dark'

sync.todos.create({
  id: 'todo_1',
  title: 'Ship v1',
})

sync.todos.records.todo_1.completed = true
await sync.sync()

Changes are saved locally automatically. Call sync() when the app needs to send changes to the server or fetch newer state. See Sync Lifecycle for recommended triggers and retry behavior.

Add the server endpoint

Expose this handler using your framework's route setup:

import { valtioSync } from 'valtio-sync/server'
import { account, todos } from './schema'

const syncServer = valtioSync({
  schema: { account, todos },
  getContext: async (request) => ({
    user: await requireUser(request),
  }),
  handlers: {
    account: {
      update: async ({ ctx, patch }) => {
        const row = await updateAccount(ctx.user.id, patch)
        return { serverVersion: row.version, record: row }
      },
    },
    todos: {
      readChanges: async ({ ctx, since }) => readTodoChanges(ctx.user.id, since),
      create: async ({ ctx, record }) => {
        const row = await createTodo(ctx.user.id, record)
        return { serverVersion: row.version, record: row }
      },
      update: async ({ ctx, op, patch }) => {
        const row = await updateTodo(ctx.user.id, op.id, patch)
        return { serverVersion: row.version, record: row }
      },
      delete: async ({ ctx, op }) => {
        const version = await deleteTodo(ctx.user.id, op.id)
        return { serverVersion: version }
      },
    },
  },
})

export const POST = syncServer.handle

Each mutation handler returns a serverVersion and may return a canonical record when the server normalizes the value.

Here, a canonical record means the final value saved by the server. See Server API for change feeds, first-device reads, and rejection handling.