Skip to main content

Offline & Connectivity

Both state bindings (Zustand and Legend State) implement an offline-first model: local writes are always instant, and pushes happen when connectivity is available.

Prerequisites: Zustand Binding or Legend State Binding

Core Model

User writes data


set(modifier)
├── Updates data locally (instant, optimistic)
├── Sets dirty = true
└── If online → flush()


flush()
├── If syncing or !dirty → no-op
└── Push data to server
├── Success → dirty = false
└── Failure → dirty remains true, error set

State Flags

FlagTypeDescription
dirtybooleanLocal changes exist that haven't been pushed
onlinebooleanWhether the app believes it has network connectivity
syncingbooleanA push or pull operation is in flight
errorstring | nullLast sync error message (null for offline pulls — see stale)
stalebooleanShown data is from persisted storage or an offline cache, not a live server response

Managing Connectivity

The online flag is manually managed — you set it based on your environment's network events.

Browser

useEffect(() => {
const handleOnline = () => store.getState().setOnline(true)
const handleOffline = () => store.getState().setOnline(false)

window.addEventListener("online", handleOnline)
window.addEventListener("offline", handleOffline)

return () => {
window.removeEventListener("online", handleOnline)
window.removeEventListener("offline", handleOffline)
}
}, [])

React Native

import NetInfo from "@react-native-community/netinfo"

useEffect(() => {
const unsubscribe = NetInfo.addEventListener(({ isConnected }) => {
store.getState().setOnline(!!isConnected)
})
return unsubscribe
}, [])

Multiple Stores

If you have multiple stores, update them all together:

const stores = [settingsStore, notesStore]

const setAllOnline = (online: boolean) =>
stores.forEach((s) => s.getState().setOnline(online))

window.addEventListener("online", () => setAllOnline(true))
window.addEventListener("offline", () => setAllOnline(false))

Flush-on-Reconnect

When setOnline(true) is called and the store has dirty: true, it automatically triggers flush(). No manual intervention needed.

// User edits while offline → dirty = true
store.getState().set((d) => ({ ...d, theme: "dark" }))
// flush() is skipped because online = false

// Network returns
store.getState().setOnline(true)
// → automatically calls flush() because dirty = true

Background Flush

In mobile apps, flush pending changes when the app goes to background:

import { AppState } from "react-native"

AppState.addEventListener("change", (state) => {
if (state === "background") {
const s = store.getState()
if (s.dirty) s.flush()
}
})

Persistence Across Restarts

The Zustand binding persists data, dirty, and hash to storage (localStorage by default) under the key starfish-{name}. On cold start the store is already populated — no network round-trip needed to show the last-synced data. The first pull() then refreshes from the server, or sets stale: true if the device is offline.

  • If dirty: true is restored from storage, you have un-pushed local changes
  • Call flush() after initialization to push them, or call pull() first to reconcile
// After store creation, check for pending changes
const state = store.getState()
if (state.dirty) {
await state.flush()
}

Offline cold start

Cold start (device offline)


zustand persist rehydrates starfish-{name}
→ data = last-synced snapshot (instantly, no network)
→ hash = last-known server hash (for safe push on reconnect)


pull() → transport fails → stale = true, error = null
(data is preserved; UI shows last-synced state + stale indicator)


setOnline(true) + reconnect
→ pull() succeeds → stale = false
→ if dirty: flush() → push pending writes

No client cache is required for this behavior — the persisted store entry is the offline-first source of truth for reads. The client cache option remains available when you need ciphertext-at-rest offline storage or stale-while-revalidate on specific HTTP status codes.

Race Condition Safety

The flush() method guards against concurrent pushes:

// Simplified logic inside flush()
if (syncing || !dirty) return // no-op

Multiple calls to flush() (e.g., from set() + setOnline() firing close together) are safe — only one push runs at a time.

Error Recovery

Failed pushes set error but keep dirty: true. The data is not lost:

  • On next set() call (if online): flush() retries
  • On setOnline(true): flush() retries
  • Manual: call flush() directly

Sync Status Indicators

Derive a single sync status from the store flags to give users clear feedback:

type SyncStatusValue = "synced" | "pending" | "syncing" | "error" | "offline"

function deriveSyncStatus(state: StarfishState): {
status: SyncStatusValue
message: string
} {
if (!state.online) return { status: "offline", message: "No connection" }
if (state.error) return { status: "error", message: state.error }
if (state.syncing) return { status: "syncing", message: "Saving..." }
if (state.dirty) return { status: "pending", message: "Unsaved changes" }
return { status: "synced", message: "All changes saved" }
}

React component

function SyncStatusBadge() {
// Custom equality prevents re-renders when the derived status hasn't changed
const status = useStore(
store,
deriveSyncStatus,
(a, b) => a.status === b.status && a.message === b.message,
)
const flush = useStore(store, (s) => s.flush)

const colors: Record<SyncStatusValue, string> = {
synced: "green",
pending: "orange",
syncing: "blue",
error: "red",
offline: "gray",
}

return (
<span style={{ color: colors[status.status] }}>
{status.message}
{status.status === "error" && (
<button onClick={flush}>Retry</button>
)}
</span>
)
}

Last sync timestamp

Track when the last successful sync happened for a "Last synced X minutes ago" display:

let lastSyncedAt: number | null = null

store.subscribe(
(state) => state.syncing,
(syncing, prevSyncing) => {
// Push or pull just completed successfully
if (prevSyncing && !syncing && !store.getState().error) {
lastSyncedAt = Date.now()
}
},
)

function useLastSynced(): string {
const [label, setLabel] = useState("")

useEffect(() => {
const interval = setInterval(() => {
if (!lastSyncedAt) {
setLabel("Never synced")
} else {
const seconds = Math.floor((Date.now() - lastSyncedAt) / 1000)
if (seconds < 10) setLabel("Just now")
else if (seconds < 60) setLabel(`${seconds}s ago`)
else setLabel(`${Math.floor(seconds / 60)}m ago`)
}
}, 5000)
return () => clearInterval(interval)
}, [])

return label
}

Multiple stores

When your app has multiple Starfish stores, aggregate their status:

// One useStore call per store — hooks must not be called in loops
function useGlobalSyncStatus(): SyncStatusValue {
const s1 = useStore(settingsStore, (s) => deriveSyncStatus(s).status)
const s2 = useStore(notesStore, (s) => deriveSyncStatus(s).status)
const s3 = useStore(tasksStore, (s) => deriveSyncStatus(s).status)
const statuses = [s1, s2, s3]

// Worst status wins
if (statuses.includes("error")) return "error"
if (statuses.includes("syncing")) return "syncing"
if (statuses.includes("pending")) return "pending"
if (statuses.includes("offline")) return "offline"
return "synced"
}

Polling / Periodic Pull

The SDK doesn't auto-poll — pulls are explicit. For apps that need to stay in sync with changes from other devices, set up periodic polling.

Basic polling

function startPolling(store: StoreApi<StarfishStore>, intervalMs = 30_000) {
const timer = setInterval(() => {
const { online, syncing } = store.getState()
if (online && !syncing) {
store.getState().pull()
}
}, intervalMs)

return () => clearInterval(timer)
}

// Start on mount, stop on unmount
useEffect(() => {
const stop = startPolling(settingsStore, 30_000)
return stop
}, [])

Adaptive polling

Adjust the interval based on network conditions and user activity:

function startAdaptivePolling(store: StoreApi<StarfishStore>) {
let intervalMs = 10_000

// Adapt to network quality (browser Network Information API)
if ("connection" in navigator) {
const conn = (navigator as any).connection
const intervals: Record<string, number> = {
"slow-2g": 120_000,
"2g": 60_000,
"3g": 30_000,
"4g": 10_000,
}
intervalMs = intervals[conn?.effectiveType] ?? 15_000
}

let timer: ReturnType<typeof setInterval>
let paused = false

function schedule() {
timer = setInterval(() => {
if (paused) return
const { online, syncing } = store.getState()
if (online && !syncing) {
store.getState().pull()
}
}, intervalMs)
}

schedule()

return {
/** Pause polling (e.g., when app is in background) */
pause: () => { paused = true },
/** Resume polling */
resume: () => { paused = false },
/** Stop polling permanently */
stop: () => clearInterval(timer),
}
}

Pause in background

Avoid wasting battery when the app is not visible:

const polling = startAdaptivePolling(store)

// Browser
document.addEventListener("visibilitychange", () => {
if (document.hidden) polling.pause()
else polling.resume()
})

// React Native
AppState.addEventListener("change", (state) => {
if (state === "active") polling.resume()
else polling.pause()
})

Pull-on-focus

An alternative to polling — pull only when the user returns to the app:

// Browser: pull when tab becomes visible
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
const { online, syncing } = store.getState()
if (online && !syncing) store.getState().pull()
}
})

// React Native: pull when app comes to foreground
AppState.addEventListener("change", (state) => {
if (state === "active") {
const { online, syncing } = store.getState()
if (online && !syncing) store.getState().pull()
}
})

This is less aggressive than polling but ensures the user always sees fresh data when they open the app.

Next Steps