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 inlocalStorage.session: local-only proxy stored insessionStorage.status: Valtio proxy withcold,hydrating,ready, orclosedphase 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 reachfetch.adoptLocalData(source, options): copy local synced state from another client, usually from an anonymous namespace into a new authenticated namespace.clearLocalData()andreset(): 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
SyncResponseto replace remote reads and acknowledgements; or - return
nullto 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()orsync(). - 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.
Preserve related records
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: trueis 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:
schemaVersionandmigrationsfor local cache migrations.conflictis reserved for conflict mode. The current v1 runtime behavior isrejectStale.fetchfor tests, non-browser runtimes, or custom request behavior.
Storage adapters
Pass storage adapter options to the constructor or hydrate(adapter):
namespacestoragelocalStorageandsessionStorageindexedDBbroadcast
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.