@ursalock/zustand
Encrypted persistence middleware for Zustand stores.
Installation
Section titled “Installation”npm install @ursalock/zustand @ursalock/crypto zustandThe main middleware that adds encrypted persistence and cloud sync.
import { create, type StateCreator } from "zustand";import { vault, type VaultOptionsJwk } from "@ursalock/zustand";import type { CipherJWK } from "@ursalock/crypto";
interface MyState { count: number; increment: () => void;}
function createStore(cipherJwk: CipherJWK) { const storeCreator: StateCreator<MyState> = (set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })), });
const options: VaultOptionsJwk<MyState> = { name: "my-store", cipherJwk, server: "https://vault.example.com", getToken: () => getAuthToken(), };
return create(vault(storeCreator, options));}VaultOptionsJwk
Section titled “VaultOptionsJwk”Options for the vault middleware using a JWK encryption key (derived from passkey).
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | - | Unique identifier for this vault |
cipherJwk | CipherJWK | Yes | - | JWK encryption key from passkey PRF |
server | string | No | - | Server URL for cloud sync |
getToken | () => string | null | No* | - | Auth token getter (*required with server) |
partialize | (state) => partial | No | (s) => s | Select which state to persist |
merge | (persisted, current) => merged | No | shallow merge | How to merge persisted state |
skipHydration | boolean | No | false | Skip auto-hydration on init |
syncInterval | number | No | 30000 | Auto-sync interval in ms (0 to disable) |
storage | VaultStorage | No | localStorage | Custom storage backend |
prefix | string | No | "ursalock:" | Storage key prefix |
onRehydrateStorage | (state) => callback | No | - | Hydration lifecycle hook |
Example with All Options
Section titled “Example with All Options”const options: VaultOptionsJwk<MyState, PersistedState> = { name: "my-store", cipherJwk,
// Cloud sync server: "https://vault.example.com", getToken: () => vaultClient.getToken(), syncInterval: 60000, // Sync every minute
// Partial persistence partialize: (state) => ({ notes: state.notes, settings: state.settings, // Don't persist UI state like currentNoteId }),
// Custom merge merge: (persisted, current) => ({ ...current, ...persisted, // Always use current UI state isLoading: current.isLoading, }),
// Lifecycle skipHydration: false, onRehydrateStorage: () => (state, error) => { if (error) console.error("Hydration failed:", error); else console.log("Hydrated:", state); },};Store Extensions
Section titled “Store Extensions”The middleware adds a vault object to the store:
const store = create(vault(storeCreator, options));
// Access vault methodsstore.vault.sync();store.vault.push();store.vault.pull();store.vault.rehydrate();store.vault.hasHydrated();store.vault.getSyncStatus();store.vault.hasPendingChanges();store.vault.clearStorage();store.vault.onHydrate(callback);store.vault.onFinishHydration(callback);vault.sync()
Section titled “vault.sync()”Full bidirectional sync with server.
await store.vault.sync();- Pushes local state (encrypted) to server
- Pulls latest from server
- Merges using Last-Write-Wins
vault.push()
Section titled “vault.push()”Push local state to server.
await store.vault.push();vault.pull()
Section titled “vault.pull()”Pull latest state from server.
const hasChanges = await store.vault.pull();// Returns true if server had newer datavault.rehydrate()
Section titled “vault.rehydrate()”Reload state from local storage.
await store.vault.rehydrate();vault.hasHydrated()
Section titled “vault.hasHydrated()”Check if initial hydration is complete.
if (store.vault.hasHydrated()) { // Safe to read store}vault.getSyncStatus()
Section titled “vault.getSyncStatus()”Get current sync status.
const status = store.vault.getSyncStatus();// "idle" | "syncing" | "synced" | "error" | "offline"vault.hasPendingChanges()
Section titled “vault.hasPendingChanges()”Check if there are unsynced local changes.
if (store.vault.hasPendingChanges()) { await store.vault.push();}vault.clearStorage()
Section titled “vault.clearStorage()”Delete all local and server data for this vault.
await store.vault.clearStorage();vault.onHydrate()
Section titled “vault.onHydrate()”Subscribe to hydration start.
const unsubscribe = store.vault.onHydrate((state) => { console.log("Hydration starting");});vault.onFinishHydration()
Section titled “vault.onFinishHydration()”Subscribe to hydration complete.
const unsubscribe = store.vault.onFinishHydration((state) => { console.log("Hydrated:", state);});useSyncStatus Hook
Section titled “useSyncStatus Hook”React hook for sync status with auto-updates.
import { useSyncStatus } from "@ursalock/zustand";
function SyncIndicator() { const status = useSyncStatus(store);
return <span>Status: {status}</span>;}import type { VaultOptionsJwk, VaultOptionsLegacy, SyncStatus, VaultStorage,} from "@ursalock/zustand";
type SyncStatus = "idle" | "syncing" | "synced" | "error" | "offline";
interface VaultOptionsJwk<S, PersistedState = S> { name: string; cipherJwk: CipherJWK; server?: string; getToken?: () => string | null; partialize?: (state: S) => PersistedState; merge?: (persisted: unknown, current: S) => S; skipHydration?: boolean; syncInterval?: number; storage?: VaultStorage; prefix?: string; onRehydrateStorage?: (state: S) => ((state?: S, error?: unknown) => void) | void;}
// For CipherJWK typeimport type { CipherJWK } from "@ursalock/crypto";VaultOptionsLegacy (Deprecated)
Section titled “VaultOptionsLegacy (Deprecated)”For backward compatibility, you can use a recovery key string instead of cipherJwk:
import type { VaultOptionsLegacy } from "@ursalock/zustand";
const options: VaultOptionsLegacy<MyState> = { name: "my-store", recoveryKey: "ABCD-EFGH-...", // 52-char recovery key // ... other options};Note: The passkey-based VaultOptionsJwk is recommended for new apps.
createVaultStorage
Section titled “createVaultStorage”Create a custom encrypted storage backend.
import { createVaultStorage, type JwkEncryptedStorageOptions } from "@ursalock/zustand";
const storage = createVaultStorage({ cipherJwk, prefix: "my-app:", storage: sessionStorage, // Use sessionStorage instead of localStorage});Error Handling
Section titled “Error Handling”try { await store.vault.sync();} catch (error) { if (error.message.includes("vault_already_exists")) { // Pull first, then push await store.vault.pull(); } else if (error.message.includes("401")) { // Token expired, re-authenticate await refreshAuth(); }}