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
16// Jotai has no single global store by default — you either use the
17// implicit default store (Provider-less) or an explicit one via
18// createStore(). Flux's adapter needs a concrete store instance to
19// call .set() on, so an explicit store is the common pairing:
20export 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})
13
14// createJotaiStoreAdapter takes the atom first, then the store
15// instance that owns it — wrapping store.get(atom) and store.set(atom, data)
16// to satisfy Flux's { getState, setState } adapter contract.
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. dispatchToStore's fallback
4// path (store.setState(data)) has nothing to call on a bare atom,
5// so Jotai needs an adapter to bridge the two:
6
7// hydrateState(store, data) if provided
8// -> else store.setState(data) — this is what the adapter exists
9// to make possible for an atom/store pair
10// -> else warn and return
11
12// createJotaiStoreAdapter wraps store.set(atom, data) behind a
13// setState(data)-shaped call so dispatchToStore's fallback path
14// works on Jotai exactly as it does on Zustand.
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 // ── 1. Array payload — bootstrap, IDB hydration, or full sync ──
11 if (Array.isArray(data)) {
12 jotaiStore.set(notificationsAtom, data as Notification[])
13 return
14 }
15
16 // ── 2. Object payload — single delta frame from live stream ────
17 if (typeof data === 'object') {
18 // DELETE frame
19 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
20 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
21 if (idToRemove) {
22 const current = jotaiStore.get(notificationsAtom)
23 jotaiStore.set(
24 notificationsAtom,
25 current.filter((n) => n.id !== idToRemove)
26 )
27 }
28 return
29 }
30
31 // Extract record identifier and payload
32 const rawRecord = data.data ?? data.new ?? data
33 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
34 if (!recordId) return
35
36 const current = jotaiStore.get(notificationsAtom)
37 const existingIdx = current.findIndex((n) => n.id === recordId)
38
39 if (existingIdx !== -1) {
40 // Sparse UPDATE — shallow merge fields onto existing record
41 const merged = [...current]
42 merged[existingIdx] = { ...merged[existingIdx], ...rawRecord, id: recordId }
43 jotaiStore.set(notificationsAtom, merged)
44 } else {
45 // CREATE — prepend new record
46 const normalized = { id: recordId, ...rawRecord } as Notification
47 jotaiStore.set(notificationsAtom, [normalized, ...current])
48 }
49 }
50 },
51}
Or keep createJotaiStoreAdapter and perform the sparse merge inside hydrateState:
typescript
1// Alternatively, use hydrateState on register() to handle sparse
2// delta frames while retaining createJotaiStoreAdapter:
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 hydrateState: (store, data) => {
12 if (!data) return
13
14 if (Array.isArray(data)) {
15 store.setState(data)
16 return
17 }
18
19 if (typeof data === 'object') {
20 const current = store.getState() as Notification[]
21
22 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
23 const id = String(data.id ?? data.data?.id ?? '')
24 if (id) store.setState(current.filter((n) => n.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((n) => n.id === recordId)
33 if (idx !== -1) {
34 const next = [...current]
35 next[idx] = { ...next[idx], ...rawRecord, id: recordId }
36 store.setState(next)
37 } else {
38 store.setState([{ id: recordId, ...rawRecord }, ...current])
39 }
40 }
41 },
42})
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
1// If your app renders <Provider store={jotaiStore}> from
2// 'jotai/react', pass that SAME store instance into both the
3// Provider and createJotaiStoreAdapter — two different store
4// instances mean the adapter writes to one atom graph while your
5// components read from another, and updates silently never appear.
6
7import { Provider } from 'jotai/react'
8import { jotaiStore } from './store'
9
10function App() {
11 return (
12 <Provider store={jotaiStore}>
13 {/* ... */}
14 </Provider>
15 )
16}
Mismatched store instances fail silently

There's 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's not dispatching, check this first.

6

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
1// scope works identically on a Jotai registration as on any other
2// state manager — it lives in the registration/dispatch/IDB layers
3// above the store, not in the adapter layer:
4
5flux.register({
6 store: notificationsAdapter,
7 channel: 'notifications',
8 event: 'UPDATE',
9 idbKey: 'notifications:latest',
10 ttl: 'short',
11 scope: 'user',
12 ingestionType: 'COLLECTION_ALL',
13})
7

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
8
9// All state managers combine identically with scope, ttl, ingestionType,
10// and every other registration field — the adapter layer is
11// unaware of scoping, which lives entirely above it.

For Zustand, Flux's native option, see state-managers/zustand. For Redux, see state-managers/redux. For Stunk, see state-managers/stunk. For every other registration field alongside store, see api/register.