Client API

Create a client to read and edit local state, then send changes to your server.

Create a client

Import the client entrypoint:

import { valtioSync } from 'valtio-sync/client'

Create a client with an endpoint and schema, then explicitly activate its local persistence:

const sync = valtioSync({
  endpoint: '/api/sync',
  schema: { account, todos },
  storage: { namespace: `my-app:${user.id}` },
})

await sync.hydrate()

Use a stable per-user namespace when multiple users can sign into the same browser. It separates each user's:

  • IndexedDB records.
  • Local storage and session storage.
  • BroadcastChannel messages between tabs.

Constructing the client does not open or read storage. hydrate() loads it.

Client properties and methods

The returned object exposes:

  • account: synced singleton account proxy.
  • one direct property for each named collection in the schema.
  • device: local-only proxy stored in localStorage.
  • session: local-only proxy stored in sessionStorage.
  • status: Valtio proxy with cold, hydrating, ready, or closed phase plus sync, dirty, online, and error state.
  • hydrate(): activate the constructor-provided default adapter.
  • hydrate(adapter): replace local persistence with another adapter.
  • flush(): wait for pending local writes and recompute pending ops.
  • sync(): flush and POST pending ops to the configured endpoint.
  • interceptTransport(interceptor): intercept protocol requests before they reach fetch.
  • adoptLocalData(source, options): copy local synced state from another client, usually from an anonymous namespace into a new authenticated namespace.
  • clearLocalData() and reset(): clear local sync, device, and session state.
  • clearCollection(collection): clear one collection from local storage.
  • close(): unsubscribe listeners, timers, channels, and storage handles.

Edit collections

Collection APIs expose:

const todo = sync.todos.create({ id: 'todo_1', title: 'Ship' })
sync.todos.update('todo_1', { completed: true })
sync.todos.records.todo_1.title = 'Ship v1'
sync.todos.delete('todo_1')

sync.todos.get('todo_1')
sync.todos.list()
await sync.todos.flush()
await sync.todos.sync()

Collection names cannot collide with built-in client properties such as account, device, hydrate, sync, or debug.

Direct proxy mutations and collection helper calls both become dirty sync operations. Local writes are batched briefly; call flush() before tests or before inspecting debug.getPendingOps().

Local persistence is automatic, but remote sync starts only when the application calls sync(). Failed network syncs retry automatically; creating dirty state alone does not schedule a remote request. See Sync Lifecycle for the complete timing, retry, and freshness model.

Transport Interception

interceptTransport() installs scoped middleware around future sync attempts. The interceptor receives the complete protocol request and a next transport function:

const removeInterceptor = sync.interceptTransport((request, next) => {
  if (developmentScenarioActive) {
    return fixtureTransport(request)
  }
  return next(request)
})

// Later:
removeInterceptor()

An interceptor can:

  • call next(request) to pass through;
  • call next({ ...request, ops: [] }) with a modified request to allow remote reads without sending local writes;
  • return a synthetic SyncResponse to replace remote reads and acknowledgements; or
  • return null to drop the entire attempt without treating it as a transport failure.

Dropping an attempt or removing its writes leaves those writes pending in local storage. A later sync can still upload them.

A synthetic acknowledgement is processed like a server acknowledgement. It may clear matching dirty operations.

Protect development fixtures

For launchable development fixtures, keep the real client but activate write protection before switching to isolated memory storage:

const removeWriteProtection = sync.interceptTransport(preventRemoteWrites)
await sync.hydrate(createMemoryStorageAdapter({ namespace: `my-app:scenario:${scenarioId}` }))

try {
  installScenarioState(sync)
} finally {
  await sync.hydrate()
  removeWriteProtection()
}

preventRemoteWrites removes outgoing operations but still fetches remote changes. Fixture writes stay dirty in the memory adapter.

Hydrating the default adapter replaces the fixture state in the active client. Do this before removing write protection: a storage namespace separates local data but does not change server authentication.

Installing or removing an interceptor affects future sync attempts; an already running request keeps the interceptor chain with which it started. The returned removal function is idempotent.

Explicit Hydration and Context Replacement

hydrate() loads saved state and activates local storage. The client requires a default adapter at construction, but does not open or read it yet.

Wait for hydration before:

  • Editing collections.
  • Calling flush() or sync().
  • Pruning or clearing local data.
  • Adopting data from another client.

An adapter with only a namespace uses IndexedDB and browser storage:

const sync = valtioSync({
  endpoint,
  schema,
  storage: { namespace: `my-app:${user.id}` },
})
await sync.hydrate()

Switch storage adapters

Calling hydrate() again settles work in the current context before replacing its public state with fresh objects. It handles:

  • Pending writes.

  • Active sync work.

  • Retries.

  • Messages between tabs.

  • hydrate(adapter) activates the supplied adapter.

  • hydrate() activates the default adapter supplied to the constructor.

Both forms resolve to undefined.

Handle loading and failures

Before the first hydration, public state contains schema defaults and empty collections. It is not the user's saved data:

  • Direct writes are ignored.
  • Async operations that need persistence reject.
  • Collection mutation helpers throw.

Use status.phase to control rendering and interaction. During replacement, synchronous mutations throw. Async persisted operations wait for the transition when the client was already ready.

If loading the destination fails, the previous context remains active. Preparing the destination may still have modified its storage.

A storage adapter and its explicit SyncStorage object can belong to only one live client. That client may reactivate them. close() releases ownership.

Bounded Local Replicas

collection.pruneLocal(ids) removes selected records from the local cache. It does not delete them on the server or change the sync cursor.

The client protects records with:

  • Pending creates, updates, or deletes.
  • Rejection or conflict metadata.

There is no force option.

const cutoff = Date.now() - 90 * 24 * 60 * 60 * 1000
const oldOrderIds = sync.orders
  .list()
  .filter((order) => order.orderedAt < cutoff)
  .map((order) => order.id)

const report = await sync.orders.pruneLocal(oldOrderIds)

The report separates eligible, evicted, missing, and protected IDs. Pass { dryRun: true } to run the same safety checks without writing.

Your app decides which records to keep. When pruning related collections, start with the records that reference others. Then check what remains before pruning their dependencies:

await sync.orders.pruneLocal(oldOrderIds)
const retainedProductVersionIds = new Set(
  sync.orders.list().flatMap((order) => order.productVersionIds),
)
await sync.productVersions.pruneLocal(
  sync.productVersions
    .list()
    .filter((version) => !version.current && !retainedProductVersionIds.has(version.id))
    .map((version) => version.id),
)

This keeps dependencies of records that were protected from pruning. A crash between stages only leaves extra cache data.

Storage is updated before reactive state. A record is removed only if it still matches the version checked, protecting newer edits from another tab.

A full server snapshot may restore pruned records that are still included in the server's sync scope.

Anonymous Signup Promotion

Use a stable anonymous namespace before signup:

const anonymousSync = valtioSync({
  endpoint: '/api/sync',
  schema: { account, todos },
  storage: { namespace: `my-app:anon:${anonymousId}` },
})
await anonymousSync.hydrate()

After signup succeeds and the request context is authenticated, create the new account client and adopt the anonymous local data:

const userSync = valtioSync({
  endpoint: '/api/sync',
  schema: { account, todos },
  storage: { namespace: `my-app:user:${user.id}` },
})
await userSync.hydrate()

await userSync.adoptLocalData(anonymousSync, {
  sync: true,
  clearSource: 'afterSuccessfulSync',
})

Adoption is for new accounts. The target namespace must not already have synced account state or cached records.

Imported data is prepared for upload:

  • Collection records become pending creates.
  • Account state becomes a pending account update.
  • The normal sync endpoint saves the data for the authenticated user.

Local-only device and session state are copied by default. Pass copyLocalState: false or { device: true, session: false } to change that.

The source namespace is cleared only when all three conditions hold:

  • sync: true is set.
  • The sync finishes without dirty state or errors.
  • clearSource: "afterSuccessfulSync" is set.

If the upload fails, the anonymous source cache remains available.

Client options

Client options include:

  • schemaVersion and migrations for local cache migrations.
  • conflict is reserved for conflict mode. The current v1 runtime behavior is rejectStale.
  • fetch for tests, non-browser runtimes, or custom request behavior.

Storage adapters

Pass storage adapter options to the constructor or hydrate(adapter):

  • namespace
  • storage
  • localStorage and sessionStorage
  • indexedDB
  • broadcast

Use createMemoryStorageAdapter() for an isolated adapter in tests and development scenarios.

Custom SyncStorage implementations must provide:

  • readSnapshot(): read account and collection state consistently.
  • commit(): apply all changes together, or none of them. If a condition no longer matches a stored record, return "conflict" without changing anything.

This contract can support IndexedDB, SQLite, or another transactional local database. Individual record methods are still required for focused reads, maintenance, and compatibility.

Migrating from Automatic Hydration

ready and the old split persistence constructor options were removed. Group the namespace and custom persistence configuration in the required default adapter, then hydrate explicitly:

// Before
const sync = valtioSync({ endpoint, schema, namespace, storage: syncStorage })
await sync.ready

// After
const sync = valtioSync({
  endpoint,
  schema,
  storage: { namespace, storage: syncStorage },
})
await sync.hydrate()

Application startup must await hydrate() before exposing mutation controls or starting remote sync triggers. Development-scenario cleanup returns to the default by awaiting hydrate() again.

Debug state

debug is intended for tests and diagnostics:

sync.debug.getStatus()
sync.debug.getPendingOps()
sync.debug.getDirtyRecords()
sync.debug.getRecordMeta(sync.todos, 'todo_1')
sync.debug.getLastSyncRequest()
sync.debug.getLastSyncResponse()

Keep secrets out of local state

Do not store secrets in synced records, device, or session. Browser storage and IndexedDB are not secure secret storage.