State Managers

Jotai

Jotai integrates through createJotaiStoreAdapter — a thin bridge between an atom and the store instance that owns it, or a custom adapter handling both full array hydration and live sparse delta updates.

Prerequisites
  • An atom created with atom() and a store instance from createStore() — see Jotai's docs if you're new to it
  • Familiarity with register() — see api/register
1

A plain Jotai atom and store

Nothing Flux-specific goes into either the atom or the store yet. Jotai has no implicit global store the way Zustand has a hook, so an explicit store instance is what Flux's adapter will need next.
typescript
1// A plain Jotai atom and store instance — nothing Flux-specific
2// about either yet.
3import { atom, createStore } from 'jotai'
4
5export interface Notification {
6 id: string
7 title: string
8 message: string
9 type: string
10 read: boolean
11 createdAt: string
12}
13
14export const notificationsAtom = atom<Notification[]>([])
15
16export const jotaiStore = createStore()
2

Registering with createJotaiStoreAdapter

Wrap the atom and store together with createJotaiStoreAdapter and pass the result as store — the adapter takes the atom first, then the store.
typescript
1import { createJotaiStoreAdapter } from '@tsworldtech/flux'
2import { notificationsAtom, jotaiStore } from './notificationsAtom'
3
4flux.register({
5 store: createJotaiStoreAdapter(notificationsAtom, jotaiStore),
6 channel: 'notifications',
7 event: 'UPDATE',
8 idbKey: 'notifications:latest',
9 ttl: 'medium',
10 ingestionType: 'COLLECTION_ALL',
11 diffBeforeUpdate: true,
12})
3

Why Jotai needs an adapter

An atom has no setState of its own to fall back to — it's a definition, not a container. The adapter exists purely to give dispatchToStore's fallback path something to call.
typescript
1// Jotai atoms have no setState method of their own — an atom is
2// just a definition, not a container you can call .set() on
3// directly without a store reference.
4
5// hydrateState(store, data) if provided
6// -> else store.setState(data) — this is what the adapter exists
7// to make possible for an atom/store pair
8// -> else warn and return
4

Handling sparse updates and single delta frames

When streaming live updates over WebSocket, SSE, or Socket.IO, your backend will stream single delta frames (CREATE, sparse UPDATE, and DELETE) alongside initial full array hydration payloads. Write a custom adapter object to handle both intake shapes:
typescript
1// When your realtime backend streams single delta frames (CREATE,
2// sparse UPDATE, or DELETE) alongside full array hydration payloads,
3// a custom adapter object handles both shapes cleanly:
4
5export const notificationsAdapter = {
6 getState: () => jotaiStore.get(notificationsAtom),
7 setState: (data: any) => {
8 if (!data) return
9
10 if (Array.isArray(data)) {
11 jotaiStore.set(notificationsAtom, data as Notification[])
12 return
13 }
14
15 if (typeof data === 'object') {
16 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
17 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
18 if (idToRemove) {
19 const current = jotaiStore.get(notificationsAtom)
20 jotaiStore.set(
21 notificationsAtom,
22 current.filter((n) => n.id !== idToRemove)
23 )
24 }
25 return
26 }
27
28 const rawRecord = data.data ?? data.new ?? data
29 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
30 if (!recordId) return
31
32 const current = jotaiStore.get(notificationsAtom)
33 const existingIdx = current.findIndex((n) => n.id === recordId)
34
35 if (existingIdx !== -1) {
36 const merged = [...current]
37 merged[existingIdx] = { ...merged[existingIdx], ...rawRecord, id: recordId }
38 jotaiStore.set(notificationsAtom, merged)
39 } else {
40 const normalized = { id: recordId, ...rawRecord } as Notification
41 jotaiStore.set(notificationsAtom, [normalized, ...current])
42 }
43 }
44 },
45}
Or keep createJotaiStoreAdapter and perform the sparse merge inside hydrateState:
typescript
1flux.register({
2 store: createJotaiStoreAdapter(notificationsAtom, jotaiStore),
3 channel: 'notifications',
4 event: 'UPDATE',
5 idbKey: 'notifications:latest',
6 ttl: 'medium',
7 ingestionType: 'COLLECTION_ALL',
8 hydrateState: (store, data) => {
9 if (!data) return
10
11 if (Array.isArray(data)) {
12 store.setState(data)
13 return
14 }
15
16 if (typeof data === 'object') {
17 const current = store.getState() as Notification[]
18
19 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
20 const id = String(data.id ?? data.data?.id ?? '')
21 if (id) store.setState(current.filter((n) => n.id !== id))
22 return
23 }
24
25 const rawRecord = data.data ?? data.new ?? data
26 const recordId = String(rawRecord?.id ?? data.id ?? '')
27 if (!recordId) return
28
29 const idx = current.findIndex((n) => n.id === recordId)
30 if (idx !== -1) {
31 const next = [...current]
32 next[idx] = { ...next[idx], ...rawRecord, id: recordId }
33 store.setState(next)
34 } else {
35 store.setState([{ id: recordId, ...rawRecord }, ...current])
36 }
37 }
38 },
39})
5

One store instance, everywhere

If your component tree renders a Jotai <Provider>, the store passed to it and the store passed to createJotaiStoreAdapter (or custom adapter) must be the exact same instance.
typescript
1import { Provider } from 'jotai/react'
2import { jotaiStore } from './store'
3
4function App() {
5 return (
6 <Provider store={jotaiStore}>
7 {/* ... */}
8 </Provider>
9 )
10}
Mismatched store instances fail silently

There is no error thrown if the Provider's store and the adapter's store diverge — Flux writes to one atom graph, your components read from another, and the UI simply never updates. If a Jotai-registered channel looks like it is not dispatching, check this first.

6

Deleting a record: online vs offline

Step 4's adapter already routes an incoming DELETE frame to filter the atom. The remaining piece is triggering that delete from your UI in a way that survives being offline at the moment it happens.
typescript
1// lib/flux.ts (excerpt) — deletion queue setup for the notifications channel.
2import { addQueueEntrySafe, getAllQueueEntries, removeQueueEntry } from '@tsworldtech/flux'
3import { notificationsAtom, jotaiStore } from './notificationsAtom'
4
5const NOTIFICATIONS_QUEUE_STORE = 'notifications_mutation_queue'
6
7async function deleteNotificationDirect(id: string): Promise<{ ok: boolean; error?: string }> {
8 try {
9 const res = await fetch('/api/notifications', {
10 method: 'DELETE',
11 headers: { 'Content-Type': 'application/json' },
12 body: JSON.stringify({ id }),
13 })
14 if (!res.ok) return { ok: false, error: `Request failed (${res.status})` }
15 return { ok: true }
16 } catch (e: any) {
17 return { ok: false, error: e?.message || 'Network error' }
18 }
19}
20
21async function queueNotificationDelete(id: string): Promise<void> {
22 await addQueueEntrySafe(
23 {
24 channel: 'notifications',
25 storeName: NOTIFICATIONS_QUEUE_STORE,
26 endpoint: '/api/notifications',
27 method: 'DELETE',
28 payload: { id },
29 timestamp: Date.now(),
30 dedupeKey: `notification:${id}`,
31 dedupeStrategy: 'replace',
32 } as any,
33 'MyApp',
34 )
35}
36
37async function purgePendingCreatesForNotification(id: string): Promise<void> {
38 const entries = await getAllQueueEntries('MyApp', NOTIFICATIONS_QUEUE_STORE)
39 for (const entry of entries) {
40 const isCreate = (entry.method ?? 'POST') !== 'DELETE'
41 const entryId = String(entry.payload?.id ?? '')
42 if (isCreate && entryId === id) {
43 await removeQueueEntry(entry.id, 'MyApp', NOTIFICATIONS_QUEUE_STORE)
44 }
45 }
46}
typescript
1// lib/flux.ts (excerpt) — optimistic atom removal first, then direct
2// or queued network confirmation.
3export async function deleteNotificationAndSync(
4 id: string
5): Promise<{ error: Error | null; queued: boolean }> {
6 const current = jotaiStore.get(notificationsAtom)
7 jotaiStore.set(notificationsAtom, current.filter((n) => n.id !== id))
8
9 const isTempId = id.startsWith('temp_notification_')
10 if (isTempId) {
11 await purgePendingCreatesForNotification(id)
12 return { error: null, queued: false }
13 }
14
15 await purgePendingCreatesForNotification(id)
16
17 const online = typeof navigator !== 'undefined' && navigator.onLine
18
19 if (online) {
20 const direct = await deleteNotificationDirect(id)
21 if (!direct.ok) {
22 await queueNotificationDelete(id)
23 return { error: null, queued: true }
24 }
25 await flux.dispatchLocalUpdate('notifications', { op: 'DELETE', id })
26 return { error: null, queued: false }
27 }
28
29 await queueNotificationDelete(id)
30 return { error: null, queued: true }
31}
typescript
1// notificationsAdapter.setState (from the sparse-delta step) already
2// handles DELETE frames, so the registration just needs queueConfig
3// and an onReplaySuccess that confirms the delete once it lands.
4flux.register({
5 store: notificationsAdapter,
6 channel: 'notifications',
7 table: 'notifications',
8 idbKey: 'notifications:latest',
9 ttl: 'medium',
10 ingestionType: 'COLLECTION_ALL',
11 event: '*',
12
13 queueConfig: {
14 storeName: 'notifications_mutation_queue',
15 replayEndpoint: '/api/notifications',
16 method: 'POST',
17 replayStrategy: 'fire_and_forget',
18 dedupeField: 'id',
19 dedupeStrategy: 'replace',
20
21 onReplaySuccess: async (responseBody, entry) => {
22 const method = (entry?.method ?? 'POST').toUpperCase()
23
24 if (method === 'DELETE') {
25 await flux.dispatchLocalUpdate('notifications', { op: 'DELETE', id: entry.payload?.id })
26 return
27 }
28
29 const confirmed = responseBody?.notification
30 if (confirmed) await flux.dispatchLocalUpdate('notifications', confirmed)
31 },
32 },
33})
Purge pending creates before queuing a delete

If a record only ever existed as an un-replayed queued create, there is nothing server-side to delete — remove it locally and purge the pending create instead of queuing a DELETE that would 404 on replay.

7

Combining with scope

Jotai registrations take scope exactly like any other state manager — the adapter layer has no awareness of multi-tenant scoping at all.
typescript
1flux.register({
2 store: notificationsAdapter,
3 channel: 'notifications',
4 event: 'UPDATE',
5 idbKey: 'notifications:latest',
6 ttl: 'short',
7 scope: 'user',
8 ingestionType: 'COLLECTION_ALL',
9})
8

Local writes: dispatchLocalUpdate()

A confirmed local mutation should not have to wait for the realtime layer to eventually catch up. flux.dispatchLocalUpdate(channel, data, options?) routes a server-confirmed write through the adapter's own setState() — identical to how bootstrap and realtime frames already reach the atom via store.set().
typescript
1import { atom, createStore } from 'jotai'
2
3export interface UserPreferences {
4 theme: 'light' | 'dark'
5 locale: string
6 digestFrequency: 'daily' | 'weekly' | 'off'
7}
8
9const defaultPreferences: UserPreferences = {
10 theme: 'light', locale: 'en-US', digestFrequency: 'weekly',
11}
12
13export const preferencesAtom = atom<UserPreferences>(defaultPreferences)
14export const originalPreferencesAtom = atom<UserPreferences | null>(null)
15export const preferencesStore = createStore()
16
17function normalizeRow(row: any, fallback: UserPreferences): UserPreferences {
18 if (!row) return fallback
19 return {
20 theme: row.theme ?? fallback.theme,
21 locale: row.locale ?? fallback.locale,
22 digestFrequency: row.digest_frequency ?? fallback.digestFrequency,
23 }
24}
25
26export function preferencesToRow(prefs: UserPreferences): Record<string, any> {
27 return { theme: prefs.theme, locale: prefs.locale, digest_frequency: prefs.digestFrequency }
28}
typescript
1import { preferencesAtom, originalPreferencesAtom, preferencesStore } from './preferencesAtom'
2import type { UserPreferences } from './preferencesAtom'
3
4function normalizeRow(row: any, fallback: UserPreferences): UserPreferences {
5 if (!row) return fallback
6 return {
7 theme: row.theme ?? fallback.theme,
8 locale: row.locale ?? fallback.locale,
9 digestFrequency: row.digest_frequency ?? fallback.digestFrequency,
10 }
11}
12
13export const preferencesAdapter = {
14 getState: () => [preferencesStore.get(preferencesAtom)],
15 setState: (data: any) => {
16 if (!data) return
17
18 const row = Array.isArray(data) ? data[0] : (data.data ?? data.new ?? data)
19 const current = preferencesStore.get(preferencesAtom)
20 const normalized = normalizeRow(row, current)
21
22 preferencesStore.set(preferencesAtom, normalized)
23 preferencesStore.set(originalPreferencesAtom, normalized)
24 },
25}
26
27flux.register({
28 store: preferencesAdapter,
29 channel: 'preferences',
30 event: 'UPDATE',
31 idbKey: 'preferences:settings',
32 ttl: 'medium',
33 scope: 'user',
34 ingestionType: 'SNAPSHOT',
35 diffBeforeUpdate: true,
36})
typescript
1import { preferencesAtom, originalPreferencesAtom, preferencesStore, preferencesToRow } from './preferencesAtom'
2import { updateUserPreferences } from './actions/preferences'
3
4export async function savePreferencesAndSync(): Promise<void> {
5 const current = preferencesStore.get(preferencesAtom)
6 const original = preferencesStore.get(originalPreferencesAtom)
7 const isDirty = original ? JSON.stringify(current) !== JSON.stringify(original) : false
8 if (!isDirty) return
9
10 let confirmedRow: any
11 try {
12 confirmedRow = await updateUserPreferences(preferencesToRow(current))
13 } catch (err) {
14 console.warn('[preferences] Save failed:', err)
15 return
16 }
17
18 await flux.dispatchLocalUpdate('preferences', confirmedRow ?? preferencesToRow(current))
19}
Confirmed writes only

Only call dispatchLocalUpdate after the server has accepted the write. It persists to IDB and mirrors to other open tabs immediately — it is a confirmed-write dispatch, not an optimistic one.

9

Jotai vs. the other adapters

Jotai is one of two state managers that benefit from an explicit adapter factory — the other being Redux.
typescript
1// State manager support:
2
3// Zustand — native. store.setState(data) called directly, no
4// adapter, no wrapper.
5// Redux — createReduxStoreAdapter(store, actionCreator) or custom adapter object
6// Jotai — createJotaiStoreAdapter(atom, store) or custom adapter object
7// Other — any state manager reachable via a custom hydrateState / adapter
Fine-Grained Atom Re-renders

Because Jotai manages atomic dependency graphs, updating individual atom values via the adapter only re-renders the specific leaf components subscribed to those atoms, bypassing broader context tree re-renders during high-frequency ingestion.

For Zustand, Flux's native option, see state_managers/zustand. For Redux, see state_managers/redux. 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