State Managers

Zustand

Zustand is Flux's native state manager — no adapter, no wrapper, no glue code. Pass the store hook directly to register() and Flux calls store.setState() on it directly.

Prerequisites
  • A Zustand store created with create() — see Zustand's docs if you're new to it
  • Familiarity with register() — see api/register
1

A plain Zustand store with dual-payload support

Writing a store for Flux requires structuring setState to handle both initial array payloads (from cold-boot IndexedDB hydration and bootstrap) and single live delta frames (CREATE, sparse field UPDATE, and DELETE):
typescript
1// src/store/useTasksStore.ts
2import { create } from 'zustand'
3
4export interface Task {
5 id: string
6 title: string
7 description: string
8 status: string
9}
10
11interface TasksStore {
12 tasks: Task[]
13 isHydrated: boolean
14 // Required by Flux — dispatchToStore calls store.setState(data) directly.
15 // Must handle both array hydration payloads (bootstrap/IDB cold boot)
16 // and single delta frame objects (CREATE, sparse UPDATE, DELETE).
17 setState: (data: any) => void
18}
19
20export const useTasksStore = create<TasksStore>((set, get) => ({
21 tasks: [],
22 isHydrated: false,
23 setState: (data: any) => {
24 if (!data) return
25
26 // ── 1. Array payload — bootstrap, IDB hydration, or full sync ──
27 if (Array.isArray(data)) {
28 set({ tasks: [...data], isHydrated: true })
29 return
30 }
31
32 // ── 2. Object payload — single delta frame from live stream ────
33 if (typeof data === 'object') {
34 const current = get().tasks
35
36 // DELETE
37 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
38 const id = String(data.id ?? data._id ?? data.data?.id ?? '')
39 if (id) set({ tasks: current.filter((t) => t.id !== id) })
40 return
41 }
42
43 const rawRecord = data.data ?? data.new ?? data
44 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
45
46 if (!recordId) return
47
48 const idx = current.findIndex((t) => t.id === recordId)
49 if (idx !== -1) {
50 // Sparse UPDATE — shallow merge fields onto existing record
51 const next = [...current]
52 next[idx] = { ...next[idx], ...rawRecord, id: recordId }
53 set({ tasks: next, isHydrated: true })
54 } else {
55 // CREATE — prepend new record
56 set({
57 tasks: [{ id: recordId, ...rawRecord }, ...current],
58 isHydrated: true,
59 })
60 }
61 }
62 },
63}))
2

Registering it

The store field takes the Zustand hook directly — no createXAdapter() call in between, unlike Redux, Jotai, or Stunk.
typescript
1import { useTasksStore } from './useTasksStore'
2
3flux.register({
4 store: useTasksStore,
5 channel: 'tasks',
6 event: 'UPDATE',
7 idbKey: 'tasks:latest',
8 ttl: 'medium',
9 ingestionType: 'COLLECTION_ALL',
10 diffBeforeUpdate: true,
11})
12
13// No adapter import, no wrapper call — the store field takes the
14// Zustand hook directly. This is the only state manager Flux treats
15// as a first-class citizen with zero glue code.
3

Why Zustand needs no adapter

Every state manager ultimately has to be reachable through hydrateState or a fallback setState call. Zustand's hook already exposes setState natively, so Flux's default fallback path works on it unmodified — there is nothing an adapter needs to translate.
typescript
1// dispatchToStore's store-write step, unchanged for Zustand:
2
3// hydrateState(store, data) if provided
4// -> else store.setState(data) directly
5// -> else warn and return
6
7// Because 'store' IS the Zustand hook itself, store.setState is
8// always available — Flux never needs an adapter wrapper to call
9// into Zustand safely.
4

Handling sparse updates with hydrateState (alternative)

If you want to keep your Zustand store actions minimal, you can delegate sparse merging and delta frame handling to hydrateState inside the registration instead:
typescript
1// If you prefer a simpler store action without inline delta branching,
2// handle sparse merges inside hydrateState on register():
3
4flux.register({
5 store: useTasksStore,
6 channel: 'tasks',
7 event: 'UPDATE',
8 idbKey: 'tasks:latest',
9 ttl: 'medium',
10 ingestionType: 'COLLECTION_ALL',
11 hydrateState: (store, data) => {
12 if (!data) return
13
14 if (Array.isArray(data)) {
15 store.getState().setTasks(data)
16 return
17 }
18
19 if (typeof data === 'object') {
20 const current = store.getState().tasks
21
22 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
23 const id = String(data.id ?? data.data?.id ?? '')
24 if (id) store.getState().setTasks(current.filter((t) => t.id !== id))
25 return
26 }
27
28 const rawRecord = data.data ?? data.new ?? data
29 const recordId = String(rawRecord?.id ?? data.id ?? '')
30 if (!recordId) return
31
32 const idx = current.findIndex((t) => t.id === recordId)
33 if (idx !== -1) {
34 const next = [...current]
35 next[idx] = { ...next[idx], ...rawRecord, id: recordId }
36 store.getState().setTasks(next)
37 } else {
38 store.getState().setTasks([{ id: recordId, ...rawRecord }, ...current])
39 }
40 }
41 },
42})
5

Deleting a record: online vs offline

A store that already handles DELETE frames (Step 1) only covers the intake side. The other half is the deletion flow itself — how a delete gets from a click in your UI to a confirmed removal on the server, whether or not the device is connected at that moment. The pattern mirrors create/update exactly: try direct, fall back to the queue, confirm once replayed.
typescript
1// lib/flux.ts (excerpt) — a dedicated mutation queue for deletes on
2// this channel. A DELETE is queued exactly like a POST — the method
3// on the queue entry is what tells the replay engine (and your API
4// route) which kind of write it's replaying.
5import { addQueueEntrySafe, getAllQueueEntries, removeQueueEntry } from '@tsworldtech/flux'
6import { useTasksStore } from '../store/useTasksStore'
7
8const TASKS_QUEUE_STORE = 'tasks_mutation_queue'
9
10async function deleteTaskDirect(id: string): Promise<{ ok: boolean; error?: string }> {
11 try {
12 const res = await fetch('/api/tasks', {
13 method: 'DELETE',
14 headers: { 'Content-Type': 'application/json' },
15 body: JSON.stringify({ id }),
16 })
17 if (!res.ok) return { ok: false, error: `Request failed (${res.status})` }
18 return { ok: true }
19 } catch (e: any) {
20 return { ok: false, error: e?.message || 'Network error' }
21 }
22}
23
24async function queueTaskDelete(id: string): Promise<void> {
25 await addQueueEntrySafe(
26 {
27 channel: 'tasks',
28 storeName: TASKS_QUEUE_STORE,
29 endpoint: '/api/tasks',
30 method: 'DELETE',
31 payload: { id },
32 timestamp: Date.now(),
33 dedupeKey: `task:${id}`,
34 dedupeStrategy: 'replace',
35 } as any,
36 'MyApp',
37 )
38}
39
40// A record deleted offline might still have an un-replayed CREATE sitting
41// in the same queue (it was added while offline too). If so, that pending
42// add must be purged — otherwise replay could recreate a record you just
43// asked to delete.
44async function purgePendingCreatesForTask(id: string): Promise<void> {
45 const entries = await getAllQueueEntries('MyApp', TASKS_QUEUE_STORE)
46 for (const entry of entries) {
47 const isCreate = (entry.method ?? 'POST') !== 'DELETE'
48 const entryId = String(entry.payload?.id ?? '')
49 if (isCreate && entryId === id) {
50 await removeQueueEntry(entry.id, 'MyApp', TASKS_QUEUE_STORE)
51 }
52 }
53}
typescript
1// lib/flux.ts (excerpt) — the delete flow: optimistic UI removal first,
2// then either a direct network call or a queued one depending on
3// connectivity. Structurally identical to the create/update flow —
4// nothing about deletes needs special-casing beyond the HTTP method.
5export async function deleteTaskAndSync(
6 id: string
7): Promise<{ error: Error | null; queued: boolean }> {
8 // 1. Optimistic removal — user sees it disappear immediately either way
9 useTasksStore.getState().setState({ op: 'DELETE', id })
10
11 // 2. A record that only ever existed as a queued, never-replayed
12 // create has nothing to delete server-side — just drop its pending
13 // create and stop here.
14 const isTempId = id.startsWith('temp_task_')
15 if (isTempId) {
16 await purgePendingCreatesForTask(id)
17 return { error: null, queued: false }
18 }
19
20 await purgePendingCreatesForTask(id)
21
22 const online = typeof navigator !== 'undefined' && navigator.onLine
23
24 if (online) {
25 const direct = await deleteTaskDirect(id)
26 if (!direct.ok) {
27 await queueTaskDelete(id)
28 return { error: null, queued: true }
29 }
30 // Confirmed by the server — persist the removal to IDB and mirror
31 // it to any other open tabs.
32 await flux.dispatchLocalUpdate('tasks', { op: 'DELETE', id })
33 return { error: null, queued: false }
34 }
35
36 await queueTaskDelete(id)
37 return { error: null, queued: true }
38}
typescript
1// The channel registration — queueConfig makes offline deletes safe,
2// onReplaySuccess confirms them into the store once the connection
3// returns and the queued DELETE actually lands.
4flux.register({
5 store: useTasksStore,
6 channel: 'tasks',
7 table: 'tasks',
8 idbKey: 'tasks:latest',
9 ttl: 'medium',
10 ingestionType: 'COLLECTION_ALL',
11 diffBeforeUpdate: true,
12 event: '*',
13
14 queueConfig: {
15 storeName: 'tasks_mutation_queue',
16 replayEndpoint: '/api/tasks',
17 method: 'POST',
18 replayStrategy: 'fire_and_forget',
19 dedupeField: 'id',
20 dedupeStrategy: 'replace',
21
22 onReplaySuccess: async (responseBody, entry) => {
23 const method = (entry?.method ?? 'POST').toUpperCase()
24
25 if (method === 'DELETE') {
26 await flux.dispatchLocalUpdate('tasks', { op: 'DELETE', id: entry.payload?.id })
27 return
28 }
29
30 // A queued CREATE replaying successfully — swap the temp row for
31 // the server-confirmed record.
32 const confirmed = responseBody?.task
33 if (confirmed) await flux.dispatchLocalUpdate('tasks', confirmed)
34 },
35 },
36})
Purge pending creates before queuing a delete

If a record was created offline and never replayed, there is nothing server-side to delete yet — only a queued CREATE. Deleting it locally and purging that pending CREATE is enough; queuing a DELETE for a record the server has never seen would just produce a confusing 404 on replay.

6

Combining with scope

Zustand registrations take scope exactly like any other state manager — the store layer is completely independent of multi-tenant scoping.
typescript
1// scope works identically on a Zustand registration as on any
2// other state manager — it lives in the registration/dispatch/IDB
3// layers above the store, not in the store itself:
4
5flux.register({
6 store: useTasksStore,
7 channel: 'tasks',
8 event: 'UPDATE',
9 idbKey: 'tasks:latest',
10 ttl: 'short',
11 scope: 'user',
12 ingestionType: 'COLLECTION_ALL',
13})
7

Local writes: dispatchLocalUpdate()

A locally-confirmed mutation — a form submit, a settings change — does not have to wait for the realtime layer to eventually catch up. Once your own write is confirmed by the server, hand the result to flux.dispatchLocalUpdate(channel, data, options?) and it goes through the exact same intake path as bootstrap and realtime data — including, for a native Zustand registration, the exact same unmodified store.setState(data) call from Step 3. No adapter-specific handling is needed here at all.
typescript
1// src/store/usePreferencesStore.ts
2import { create } from 'zustand'
3
4export interface UserPreferences {
5 theme: 'light' | 'dark'
6 locale: string
7 digestFrequency: 'daily' | 'weekly' | 'off'
8}
9
10const defaultPreferences: UserPreferences = {
11 theme: 'light',
12 locale: 'en-US',
13 digestFrequency: 'weekly',
14}
15
16function normalizeRow(row: any, fallback: UserPreferences): UserPreferences {
17 if (!row) return fallback
18 return {
19 theme: row.theme ?? fallback.theme,
20 locale: row.locale ?? fallback.locale,
21 digestFrequency: row.digest_frequency ?? fallback.digestFrequency,
22 }
23}
24
25export function preferencesToRow(prefs: UserPreferences): Record<string, any> {
26 return {
27 theme: prefs.theme,
28 locale: prefs.locale,
29 digest_frequency: prefs.digestFrequency,
30 }
31}
32
33interface PreferencesStore {
34 preferences: UserPreferences
35 original: UserPreferences | null
36 setPreference: <K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => void
37 isDirty: () => boolean
38 setState: (data: any) => void
39}
40
41export const usePreferencesStore = create<PreferencesStore>((set, get) => ({
42 preferences: { ...defaultPreferences },
43 original: null,
44
45 setPreference: (key, value) => {
46 set((s) => ({ preferences: { ...s.preferences, [key]: value } }))
47 },
48
49 isDirty: () => {
50 const s = get()
51 return s.original ? JSON.stringify(s.preferences) !== JSON.stringify(s.original) : false
52 },
53
54 setState: (data: any) => {
55 if (!data) return
56
57 if (Array.isArray(data)) {
58 if (data.length === 0) return
59 const preferences = normalizeRow(data[0], get().preferences)
60 set({ preferences, original: { ...preferences } })
61 return
62 }
63
64 if (typeof data === 'object') {
65 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
66 set({ preferences: { ...defaultPreferences }, original: null })
67 return
68 }
69
70 const rawRecord = data.data ?? data.new ?? data
71 const preferences = normalizeRow(rawRecord, get().preferences)
72 set({ preferences, original: { ...preferences } })
73 }
74 },
75}))
typescript
1flux.register({
2 store: usePreferencesStore, // native Zustand hook — no adapter
3 channel: 'preferences',
4 event: 'UPDATE',
5 idbKey: 'preferences:settings',
6 ttl: 'medium',
7 scope: 'user',
8 ingestionType: 'SNAPSHOT',
9 diffBeforeUpdate: true,
10})
typescript
1// lib/flux.ts (excerpt) — save a local edit, then sync it into Flux
2import { usePreferencesStore, preferencesToRow } from '../store/usePreferencesStore'
3import { updateUserPreferences } from './actions/preferences' // your own API call
4
5export async function savePreferencesAndSync(): Promise<void> {
6 const before = usePreferencesStore.getState()
7 if (!before.isDirty()) return
8
9 let confirmedRow: any
10 try {
11 confirmedRow = await updateUserPreferences(preferencesToRow(before.preferences))
12 } catch (err) {
13 console.warn('[preferences] Save failed:', err)
14 return
15 }
16
17 await flux.dispatchLocalUpdate('preferences', confirmedRow ?? preferencesToRow(before.preferences))
18}
typescript
1// components/PreferencesForm.tsx (excerpt)
2import { usePreferencesStore } from '../store/usePreferencesStore'
3import { savePreferencesAndSync } from '../lib/flux'
4
5export function PreferencesForm() {
6 const { preferences, setPreference, isDirty } = usePreferencesStore()
7
8 return (
9 <form onSubmit={(e) => { e.preventDefault(); savePreferencesAndSync() }}>
10 <select
11 value={preferences.theme}
12 onChange={(e) => setPreference('theme', e.target.value as 'light' | 'dark')}
13 >
14 <option value="light">Light</option>
15 <option value="dark">Dark</option>
16 </select>
17
18 <button type="submit" disabled={!isDirty()}>Save</button>
19 </form>
20 )
21}
Confirmed writes only

Call dispatchLocalUpdate only after your server has accepted the mutation. It is not an optimistic-update mechanism — it persists to IDB and mirrors to other tabs immediately, so it should never carry local state that has not actually been saved.

8

Zustand vs. other state managers

Zustand is the only state manager Flux treats as native. Everything else uses an explicit adapter wrapper.
typescript
1// State manager support comparison:
2
3// Zustand — native. store.setState(data) called directly, no
4// adapter, no wrapper.
5// Redux — createReduxStoreAdapter(store, actionCreator) or custom adapter
6// Jotai — createJotaiStoreAdapter(atom, store) or custom adapter
7// Stunk — createStunkStoreAdapter(chunk)
8
9// All state managers combine identically with scope, ttl, ingestionType,
10// and every other registration field.
Direct Subscription Efficiency

Because Zustand hooks subscribe components to state slices using selector functions, invoking useTasksStore.setState() directly from Flux dispatches updates solely to components that observe the mutated records, eliminating unnecessary component subtree renders.

For Redux, see state_managers/redux. For Jotai, see state_managers/jotai. For Stunk, see state_managers/stunk. For the full offline write queue, see core_concepts/offline_queue. For every other registration field alongside store, see api/register.

Powered by Flux