Zustand Binding
createStarfishStore wraps a SyncManager in a Zustand store with persistence, optional devtools, and offline-first writes.
Prerequisites: SyncManager
Installation
npm install @drakkar.software/starfish-client zustand
npm install immer # optional, for draft-based mutations
Setup
import {
StarfishClient,
SyncManager,
bootstrapRootIdentity,
} from "@drakkar.software/starfish-client"
import { createStarfishStore } from "@drakkar.software/starfish-client/zustand"
const creds = await bootstrapRootIdentity(passphrase)
const client = new StarfishClient({
baseUrl: "https://api.example.com/v1",
capProvider: {
getCap: async () => ({ cap: creds.capCert, devEdPrivHex: creds.device.edPriv }),
},
})
// One store per collection — each syncs independently
const settingsStore = createStarfishStore({
name: "settings",
syncManager: new SyncManager({
client,
pullPath: `/pull/users/${creds.userId}/settings`,
pushPath: `/push/users/${creds.userId}/settings`,
}),
})
CreateStarfishStoreOptions
interface CreateStarfishStoreOptions {
/** Unique name, used as persistence key (prefixed with `starfish-`) */
name: string
syncManager: SyncManager
/** Pass `false` to disable persistence. Defaults to localStorage in browsers. */
storage?: StateStorage | false
/** Wrap the store with Redux DevTools. Import devtools from 'zustand/middleware' and pass it directly. */
devtools?: (storeCreator: any) => any
/** Pass `produce` from immer to enable draft-based mutations in set(). */
produce?: <T>(base: T, recipe: (draft: T) => T | void) => T
/**
* Auto re-attempt a failed flush with exponential backoff while the store
* stays dirty + online. Omit to keep the default no-retry behavior.
* Defaults when present: `maxRetries: 5`, `initialDelayMs: 500`, `maxDelayMs: 30_000`.
*/
flushRetry?: { maxRetries?: number; initialDelayMs?: number; maxDelayMs?: number }
}
Store Shape
interface StarfishState {
data: Record<string, unknown> // the synced document
syncing: boolean // operation in flight?
online: boolean // network connectivity
dirty: boolean // local changes pending push?
error: string | null // last sync error (null when offline — see stale)
hash: string | null // last-known server hash; persisted for baseHash restore
/**
* True when the currently-shown `data` is a stale (not yet confirmed-live) snapshot:
* either seeded from the client's offline cache via `seed()`, or kept from persisted
* storage when a `pull()` failed because the transport was unreachable (offline /
* DNS / timeout). A successful live pull clears it. Use it to drive an
* "offline / showing last-synced data" indicator without surfacing an error.
*/
stale: boolean
}
interface StarfishActions {
pull(): Promise<void>
set(modifier: (current: Record<string, unknown>) => Record<string, unknown>): void
restore(data: Record<string, unknown>): void
flush(): Promise<void>
setOnline(online: boolean): void
/**
* Cache-first paint: populate `data` from the client's offline read-through cache
* (if one is configured) without touching the network. A no-op without a cache or
* on a cache miss. Call before the initial `pull()` to show last-synced data
* immediately; the live pull then supersedes the seeded snapshot.
*/
seed(): Promise<void>
}
type StarfishStore = StarfishState & StarfishActions
React Components
import { useStore } from "zustand"
function Settings() {
const { data, syncing, pull, set } = useStore(settingsStore)
useEffect(() => {
pull()
}, [])
return (
<button
disabled={syncing}
onClick={() => set((d) => ({ ...d, theme: "dark" }))}
>
Theme: {(data.theme as string) ?? "default"}
</button>
)
}
Selectors
Subscribe to specific fields to avoid unnecessary re-renders:
function ThemeBadge() {
const theme = useStore(settingsStore, (s) => s.data.theme)
return <span>{theme as string}</span>
}
useStarfishState(store, selector) — sync-state selectors
Use useStarfishState when you need a single sync-state field (error, syncing, online, dirty, stale) without subscribing to the whole store or to data. It only re-renders the component when the selected value changes.
import {
useStarfishState,
useStarfishData,
} from "@drakkar.software/starfish-client/zustand"
function ErrorBanner({ store }: { store: StoreApi<StarfishStore> }) {
const error = useStarfishState(store, (s) => s.error)
if (!error) return null
return <p style={{ color: "red" }}>{error}</p>
}
Use useStarfishData(store, selector) for the data field (it uses the same selector pattern). Both hooks compose well in a single component:
function RoomChat({ store }: { store: StoreApi<StarfishStore> }) {
const messages = useStarfishData(store, (d) => d.messages as Message[])
const error = useStarfishState(store, (s) => s.error)
// Re-renders only when messages or error changes — not on every pull/hash update.
...
}
The store includes subscribeWithSelector middleware, so you can also subscribe programmatically:
settingsStore.subscribe(
(state) => state.data.theme,
(theme) => console.log("theme changed:", theme),
)
Actions
set(modifier)
Applies an optimistic local write. If the store is online, automatically calls flush().
settingsStore.getState().set((current) => ({
...current,
theme: "dark",
}))
- Updates
dataimmediately (optimistic) - Sets
dirty: true - Calls
flush()ifonlineistrue
pull()
Fetches remote data and updates the store.
await settingsStore.getState().pull()
Offline behavior — when the transport is unreachable (offline / DNS / timeout), pull() does
NOT set error. Instead it keeps the currently-shown data (which was already rehydrated from
persisted storage on cold start) and sets stale: true so the UI can show an "offline / showing
last-synced data" indicator without treating the situation as an error. HTTP errors (4xx / 5xx),
abort signals, and decrypt failures always set error as before.
This means persist-backed stores are offline-first for reads without needing a separate client
cache. On cold start, the persisted starfish-{name} entry rehydrates data immediately; the
first pull() then refreshes it from the server, or sets stale: true if the network is down.
flush()
Pushes dirty data to the server. No-op if already syncing or not dirty.
await settingsStore.getState().flush()
Self-healing retry — pass flushRetry to createStarfishStore to enable automatic re-attempts on failure:
const store = createStarfishStore({
name: "nodes",
syncManager,
flushRetry: { maxRetries: 5, initialDelayMs: 500, maxDelayMs: 30_000 },
})
After each failed flush, the store schedules the next attempt with jittered exponential backoff (min(initialDelayMs × 2^attempt, maxDelayMs) + random(100ms)). The counter resets on success, a fresh set() call, or when the store goes offline. AbortErrors are never retried. Going offline via setOnline(false) cancels any pending retry timer immediately.
setOnline(online)
Updates connectivity status. If going online with dirty data, triggers flush().
settingsStore.getState().setOnline(true)
Persistence
By default, data and dirty are persisted to localStorage under the key starfish-{name}.
// Default: localStorage
const store = createStarfishStore({ name: "settings", syncManager })
// Persists to localStorage key: "starfish-settings"
// Disable persistence
const store = createStarfishStore({ name: "settings", syncManager, storage: false })
// Custom storage (e.g., AsyncStorage for React Native)
import AsyncStorage from "@react-native-async-storage/async-storage"
const store = createStarfishStore({
name: "settings",
syncManager,
storage: {
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
removeItem: (key) => AsyncStorage.removeItem(key),
},
})
data, dirty, and hash are persisted — syncing, online, and error are transient. The persisted JSON shape is:
{ "state": { "data": { "items": [] }, "dirty": false, "hash": "<server-hash>" }, "version": 0 }
On hydration, the stored hash is automatically restored into the bound SyncManager via syncManager.setHash(hash) before any pull or push runs. This means that after a page reload, wallet lock/unlock, or app restart, the first push sends baseHash:<lastKnownHash> instead of null — avoiding a spurious 409 conflict + recovery roundtrip.
Redux DevTools
Import devtools from 'zustand/middleware' and pass it as a wrapper function. This keeps the import in your code so bundlers that don't support import.meta.env (Metro/Hermes, Expo web) are unaffected when devtools is unused.
import { devtools } from 'zustand/middleware'
const store = createStarfishStore({
name: "settings",
syncManager,
devtools: (fn) => devtools(fn, { name: 'settings' }),
})
Action labels in DevTools:
| Action | When |
|---|---|
pull/start | Pull begins |
pull/success | Pull completes |
pull/error | Pull fails |
set | Local data updated |
set/error | Modifier threw |
flush/start | Push begins |
flush/success | Push completes |
flush/error | Push fails |
setOnline | Connectivity changed |
Immer Support
Pass produce from Immer to enable draft-based mutations:
import { produce } from "immer"
const store = createStarfishStore({
name: "notes",
syncManager,
produce,
})
// Mutate the draft directly — no spread needed
store.getState().set((draft) => {
(draft.items as string[]).push("new note")
})
Multiple Stores
Create one store per collection. Each syncs independently:
const settingsStore = createStarfishStore({
name: "settings",
syncManager: new SyncManager({ client, pullPath: "/pull/.../settings", pushPath: "/push/.../settings" }),
})
// For an encrypted collection, build the encryptor from the keyring document.
// See 23-multi-recipient-delegated.md for keyring lifecycle.
const keyring = (await client.pull(`users/${creds.userId}/notes/_keyring`)).data as Keyring
const encryptor = await createKeyringEncryptor(keyring, {
kemPubHex: creds.device.kemPub,
kemPrivHex: creds.device.kemPriv,
})
const notesStore = createStarfishStore({
name: "notes",
syncManager: new SyncManager({
client,
pullPath: `/pull/users/${creds.userId}/notes`,
pushPath: `/push/users/${creds.userId}/notes`,
encryptor,
}),
})
Append-only logs
For append-only collections (a growing log rather than a read-merge-write document), use createStarfishLog instead of createStarfishStore. It's backed by an AppendLogCursor and is read-only — the store has { items, loading, online, error, checkpoint } and a single pull() action that fetches new elements and appends them. There is no set/flush/dirty/conflict surface, and no persist middleware: the cursor owns the items + checkpoint (persist by saving cursor.getItems(), rehydrate via initialItems).
import { AppendLogCursor } from "@drakkar.software/starfish-client"
import {
createStarfishLog,
useStarfishLogItems,
useLogStatus,
useLogConnectivity,
} from "@drakkar.software/starfish-client/zustand"
const cursor = new AppendLogCursor({
client,
pullPath: "/pull/events",
initialItems: await store.load(), // omit for a cold start
})
const logStore = createStarfishLog({ cursor })
await logStore.getState().pull() // fetch new, append; returns the new batch
function Feed() {
const items = useStarfishLogItems(logStore)
const status = useLogStatus(logStore) // "idle" | "loading" | "error" | "offline"
useLogConnectivity(logStore)
return <>{items.map((e) => <Row key={e.ts} {...e} />)}</>
}
Hooks: useStarfishLog, useStarfishLogItems(store, selector?), useLogStatus, subscribeLogStatus (framework-agnostic), useLogConnectivity. For React Native, createAppendLogMobileLifecycle pulls on foreground. Polling works as-is: startPolling(() => logStore.getState().pull(), () => logStore.getState()).
Next Steps
- Legend State Binding — alternative with fine-grained reactivity
- Offline & Connectivity — handling network changes