Skip to content

@ursalock/zustand

Encrypted persistence middleware for Zustand stores.

Terminal window
npm install @ursalock/zustand @ursalock/crypto zustand

The 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));
}

Options for the vault middleware using a JWK encryption key (derived from passkey).

OptionTypeRequiredDefaultDescription
namestringYes-Unique identifier for this vault
cipherJwkCipherJWKYes-JWK encryption key from passkey PRF
serverstringNo-Server URL for cloud sync
getToken() => string | nullNo*-Auth token getter (*required with server)
partialize(state) => partialNo(s) => sSelect which state to persist
merge(persisted, current) => mergedNoshallow mergeHow to merge persisted state
skipHydrationbooleanNofalseSkip auto-hydration on init
syncIntervalnumberNo30000Auto-sync interval in ms (0 to disable)
storageVaultStorageNolocalStorageCustom storage backend
prefixstringNo"ursalock:"Storage key prefix
onRehydrateStorage(state) => callbackNo-Hydration lifecycle hook
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);
},
};

The middleware adds a vault object to the store:

const store = create(vault(storeCreator, options));
// Access vault methods
store.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);

Full bidirectional sync with server.

await store.vault.sync();
  1. Pushes local state (encrypted) to server
  2. Pulls latest from server
  3. Merges using Last-Write-Wins

Push local state to server.

await store.vault.push();

Pull latest state from server.

const hasChanges = await store.vault.pull();
// Returns true if server had newer data

Reload state from local storage.

await store.vault.rehydrate();

Check if initial hydration is complete.

if (store.vault.hasHydrated()) {
// Safe to read store
}

Get current sync status.

const status = store.vault.getSyncStatus();
// "idle" | "syncing" | "synced" | "error" | "offline"

Check if there are unsynced local changes.

if (store.vault.hasPendingChanges()) {
await store.vault.push();
}

Delete all local and server data for this vault.

await store.vault.clearStorage();

Subscribe to hydration start.

const unsubscribe = store.vault.onHydrate((state) => {
console.log("Hydration starting");
});

Subscribe to hydration complete.

const unsubscribe = store.vault.onFinishHydration((state) => {
console.log("Hydrated:", state);
});

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 type
import type { CipherJWK } from "@ursalock/crypto";

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.

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
});
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();
}
}