State Managers

Redux

Redux integrates through createReduxStoreAdapter or a custom adapter object — bridging dispatchToStore's setState fallback to Redux's action dispatch model.

Prerequisites
  • A Redux store from configureStore() and action creators from createSlice() — see Redux Toolkit's docs if you're new to it
  • Familiarity with register() — see api/register
1

A plain Redux slice and store

Define a slice with reducers for full array replacements (setItems), optimistic placeholder additions (addOptimisticItem), single confirmed item upserts (upsertItem), and removals (removeItem):
typescript
1// A Redux Toolkit slice with full support for array hydrations,
2// optimistic placeholders, and single live delta upserts/deletions.
3import { createSlice, configureStore, type PayloadAction } from '@reduxjs/toolkit'
4
5export interface Item {
6 id: string
7 name: string
8 description: string
9 status: string
10 price: number
11 createdAt: string
12 updatedAt: string
13}
14
15interface ItemsState {
16 list: Item[]
17}
18
19const initialState: ItemsState = { list: [] }
20
21const itemsSlice = createSlice({
22 name: 'items',
23 initialState,
24 reducers: {
25 setItems(state, action: PayloadAction<Item[]>) {
26 state.list = Array.isArray(action.payload) ? action.payload : []
27 },
28 addOptimisticItem(state, action: PayloadAction<Item>) {
29 state.list.unshift(action.payload)
30 },
31 upsertItem(state, action: PayloadAction<Item>) {
32 const incoming = action.payload
33 const optimisticIdx = state.list.findIndex(
34 (item) =>
35 item.status === 'optimistic_pending' &&
36 item.name === incoming.name
37 )
38 if (optimisticIdx !== -1) {
39 state.list[optimisticIdx] = incoming
40 return
41 }
42 const existingIdx = state.list.findIndex((item) => item.id === incoming.id)
43 if (existingIdx !== -1) {
44 state.list[existingIdx] = { ...state.list[existingIdx], ...incoming }
45 return
46 }
47 state.list.unshift(incoming)
48 },
49 removeItem(state, action: PayloadAction<string>) {
50 state.list = state.list.filter((item) => item.id !== action.payload)
51 },
52 },
53})
54
55export const { setItems, addOptimisticItem, upsertItem, removeItem } = itemsSlice.actions
56
57export const reduxStore = configureStore({
58 reducer: { items: itemsSlice.reducer },
59})
2

Registering with createReduxStoreAdapter

For simple stores that process whole array payloads, wrap the store and action creator with createReduxStoreAdapter:
typescript
1import { createReduxStoreAdapter } from '@tsworldtech/flux'
2import { reduxStore, setItems } from './store'
3
4flux.register({
5 store: createReduxStoreAdapter(reduxStore, setItems),
6 channel: 'items',
7 event: 'UPDATE',
8 idbKey: 'items:latest',
9 ttl: 'long',
10 ingestionType: 'COLLECTION_ALL',
11})
12
13// createReduxStoreAdapter wraps store.dispatch(actionCreator(data))
14// behind a setState(data)-shaped call for standard array payloads.
3

Why Redux needs an adapter

A Redux store only ever changes through dispatch() — there is no native setState method. The adapter exists to give dispatchToStore's fallback path a concrete function to invoke.
typescript
1// A Redux store has no setState of its own — the only sanctioned
2// way to change state is store.dispatch(someAction), never a
3// direct mutation. dispatchToStore's fallback path
4// (store.setState(data)) has nothing to call on a raw Redux store,
5// so Redux 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 on top of store.dispatch()
10// -> else warn and return
11
12// createReduxStoreAdapter or a custom adapter object wraps
13// store.dispatch(...) behind a setState(data)-shaped signature.
4

Handling sparse updates & single delta events

When streaming live updates over WebSocket, SSE, or Socket.IO, your backend streams single delta frames (CREATE, sparse UPDATE, and DELETE) alongside initial array hydrations. Use a hand-written adapter object to route incoming data to the correct Redux action:
typescript
1// When your realtime backend streams single delta frames (CREATE,
2// sparse UPDATE, or DELETE) alongside full array hydration payloads,
3// write a custom Redux adapter object to dispatch the appropriate action:
4
5export const itemsAdapter = {
6 getState: () => reduxStore.getState().items.list,
7 setState: (data: any) => {
8 if (!data) return
9
10 // ── 1. Array payload — IDB cold-boot, bootstrap, or full batch sync ──
11 if (Array.isArray(data)) {
12 const current = reduxStore.getState().items.list
13 const hasOptimistic = current.some((i) => i.status === 'optimistic_pending')
14
15 if (hasOptimistic) {
16 const resolved = data.map((item) => {
17 if (item.status !== 'optimistic_pending') return item
18 const confirmed = data.find(
19 (i) => i.status !== 'optimistic_pending' && i.name === item.name
20 )
21 return confirmed ? null : item
22 }).filter(Boolean) as Item[]
23
24 reduxStore.dispatch(setItems(resolved))
25 return
26 }
27
28 reduxStore.dispatch(setItems(data))
29 return
30 }
31
32 // ── 2. Object payload — single live delta event or backfill frame ──
33 if (typeof data === 'object') {
34 // DELETE frame
35 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
36 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
37 if (idToRemove) reduxStore.dispatch(removeItem(idToRemove))
38 return
39 }
40
41 // Extract record payload & ID
42 const rawRecord = data.data ?? data.new ?? data
43 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
44
45 if (recordId) {
46 const normalizedItem = { id: recordId, ...rawRecord } as Item
47 reduxStore.dispatch(upsertItem(normalizedItem))
48 }
49 }
50 },
51}
5

Reaching for hydrateState

If you use createReduxStoreAdapter but need to dispatch multiple actions or perform side effects upon payload arrival, pass a custom hydrateState callback during registration:
typescript
1// Alternatively, use hydrateState inside register() to handle
2// multi-action dispatches or custom payload reshaping:
3
4flux.register({
5 store: createReduxStoreAdapter(reduxStore, setItems),
6 channel: 'items',
7 event: 'UPDATE',
8 idbKey: 'items:latest',
9 ttl: 'long',
10 ingestionType: 'COLLECTION_ALL',
11 hydrateState: (store, data) => {
12 if (Array.isArray(data)) {
13 store.setState(data)
14 } else if (typeof data === 'object') {
15 const record = data.data ?? data
16 reduxStore.dispatch(upsertItem(record))
17 }
18 // dispatch an additional side-effect action
19 reduxStore.dispatch(recordLastSyncedAt(Date.now()))
20 },
21})
typescript
1// createReduxStoreAdapter is built around a single action creator —
2// it calls store.dispatch(actionCreator(data)) with the raw payload.
3// For complex stores that process both array hydrations and single live
4// delta updates, use a hand-written adapter object (Step 4 above).
Need multiple actions from one channel?

createReduxStoreAdapter is deliberately single-action. If a channel needs to fan out to more than one reducer, use hydrateState or a custom adapter object as shown above.

6

Deleting a record: online vs offline

Step 4's adapter already routes an incoming DELETE frame to removeItem(). What is still missing is the other half — actually triggering a delete from your UI, and making sure it survives being offline when it happens.
typescript
1// lib/flux.ts (excerpt) — deletion queue setup for the items channel.
2import { addQueueEntrySafe, getAllQueueEntries, removeQueueEntry } from '@tsworldtech/flux'
3import { reduxStore } from './store'
4import { removeItem } from './store/itemsSlice'
5
6const ITEMS_QUEUE_STORE = 'items_mutation_queue'
7
8async function deleteItemDirect(id: string): Promise<{ ok: boolean; error?: string }> {
9 try {
10 const res = await fetch('/api/items', {
11 method: 'DELETE',
12 headers: { 'Content-Type': 'application/json' },
13 body: JSON.stringify({ id }),
14 })
15 if (!res.ok) return { ok: false, error: `Request failed (${res.status})` }
16 return { ok: true }
17 } catch (e: any) {
18 return { ok: false, error: e?.message || 'Network error' }
19 }
20}
21
22async function queueItemDelete(id: string): Promise<void> {
23 await addQueueEntrySafe(
24 {
25 channel: 'items',
26 storeName: ITEMS_QUEUE_STORE,
27 endpoint: '/api/items',
28 method: 'DELETE',
29 payload: { id },
30 timestamp: Date.now(),
31 dedupeKey: `item:${id}`,
32 dedupeStrategy: 'replace',
33 } as any,
34 'MyApp',
35 )
36}
37
38async function purgePendingCreatesForItem(id: string): Promise<void> {
39 const entries = await getAllQueueEntries('MyApp', ITEMS_QUEUE_STORE)
40 for (const entry of entries) {
41 const isCreate = (entry.method ?? 'POST') !== 'DELETE'
42 const entryId = String(entry.payload?.id ?? '')
43 if (isCreate && entryId === id) {
44 await removeQueueEntry(entry.id, 'MyApp', ITEMS_QUEUE_STORE)
45 }
46 }
47}
typescript
1// lib/flux.ts (excerpt) — optimistic Redux removal first, then
2// direct-or-queued network confirmation, same shape as create/update.
3export async function deleteItemAndSync(
4 id: string
5): Promise<{ error: Error | null; queued: boolean }> {
6 // 1. Optimistic removal via the Redux action, not setState directly
7 reduxStore.dispatch(removeItem(id))
8
9 const isTempId = id.startsWith('temp_item_')
10 if (isTempId) {
11 await purgePendingCreatesForItem(id)
12 return { error: null, queued: false }
13 }
14
15 await purgePendingCreatesForItem(id)
16
17 const online = typeof navigator !== 'undefined' && navigator.onLine
18
19 if (online) {
20 const direct = await deleteItemDirect(id)
21 if (!direct.ok) {
22 await queueItemDelete(id)
23 return { error: null, queued: true }
24 }
25 await flux.dispatchLocalUpdate('items', { op: 'DELETE', id })
26 return { error: null, queued: false }
27 }
28
29 await queueItemDelete(id)
30 return { error: null, queued: true }
31}
typescript
1// itemsAdapter.setState (Step 4 above) already routes DELETE frames to
2// removeItem(), so registering with queueConfig and an onReplaySuccess
3// that dispatches a DELETE frame is all that's needed:
4flux.register({
5 store: itemsAdapter,
6 channel: 'items',
7 table: 'items',
8 idbKey: 'items:latest',
9 ttl: 'long',
10 ingestionType: 'COLLECTION_ALL',
11 event: '*',
12
13 queueConfig: {
14 storeName: 'items_mutation_queue',
15 replayEndpoint: '/api/items',
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('items', { op: 'DELETE', id: entry.payload?.id })
26 return
27 }
28
29 const confirmed = responseBody?.item
30 if (confirmed) await flux.dispatchLocalUpdate('items', confirmed)
31 },
32 },
33})
Purge pending creates before queuing a delete

If the record was created offline and never replayed, there is no server row to delete — dispatch the local removal and clear the pending CREATE from the queue instead of queuing a DELETE that would 404.

7

Combining with scope

Redux 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 Redux 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: itemsAdapter,
7 channel: 'items',
8 event: 'UPDATE',
9 idbKey: 'items:latest',
10 ttl: 'short',
11 scope: 'user',
12 ingestionType: 'COLLECTION_ALL',
13})
8

Local writes: dispatchLocalUpdate()

A locally-confirmed mutation does not have to wait for the realtime layer to catch up. Once your backend confirms a write, hand the result to flux.dispatchLocalUpdate(channel, data, options?) and it flows through the same adapter setState() — and so the same dispatch() call — that bootstrap and realtime data already use.
typescript
1// A dedicated preferences slice — mirrors the itemsSlice pattern above,
2// but for a SNAPSHOT-style single-record channel rather than a list.
3import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
4
5export interface UserPreferences {
6 theme: 'light' | 'dark'
7 locale: string
8 digestFrequency: 'daily' | 'weekly' | 'off'
9}
10
11interface PreferencesState {
12 current: UserPreferences
13 original: UserPreferences | null
14}
15
16const defaultPreferences: UserPreferences = {
17 theme: 'light', locale: 'en-US', digestFrequency: 'weekly',
18}
19
20const initialState: PreferencesState = { current: defaultPreferences, original: null }
21
22function normalizeRow(row: any, fallback: UserPreferences): UserPreferences {
23 if (!row) return fallback
24 return {
25 theme: row.theme ?? fallback.theme,
26 locale: row.locale ?? fallback.locale,
27 digestFrequency: row.digest_frequency ?? fallback.digestFrequency,
28 }
29}
30
31export function preferencesToRow(prefs: UserPreferences): Record<string, any> {
32 return { theme: prefs.theme, locale: prefs.locale, digest_frequency: prefs.digestFrequency }
33}
34
35const preferencesSlice = createSlice({
36 name: 'preferences',
37 initialState,
38 reducers: {
39 setPreferenceField(state, action: PayloadAction<{ key: keyof UserPreferences; value: any }>) {
40 state.current = { ...state.current, [action.payload.key]: action.payload.value }
41 },
42 hydratePreferences(state, action: PayloadAction<any>) {
43 const raw = action.payload
44 if (!raw) return
45 const row = Array.isArray(raw) ? raw[0] : (raw.data ?? raw.new ?? raw)
46 const normalized = normalizeRow(row, state.current)
47 state.current = normalized
48 state.original = normalized
49 },
50 },
51})
52
53export const { setPreferenceField, hydratePreferences } = preferencesSlice.actions
54export default preferencesSlice.reducer
typescript
1// lib/flux.ts (excerpt) — bridging the preferences slice into Flux
2import { reduxStore } from './store'
3import { hydratePreferences } from './store/preferencesSlice'
4
5export const preferencesAdapter = {
6 getState: () => [reduxStore.getState().preferences.current],
7 setState: (data: any) => {
8 if (!data) return
9 reduxStore.dispatch(hydratePreferences(data))
10 },
11}
12
13flux.register({
14 store: preferencesAdapter,
15 channel: 'preferences',
16 event: 'UPDATE',
17 idbKey: 'preferences:settings',
18 ttl: 'medium',
19 scope: 'user',
20 ingestionType: 'SNAPSHOT',
21 diffBeforeUpdate: true,
22})
typescript
1// lib/flux.ts (excerpt) — save a local edit, then sync it into Flux
2import { reduxStore } from './store'
3import { preferencesToRow } from './store/preferencesSlice'
4import { updateUserPreferences } from './actions/preferences' // your own API call
5
6export async function savePreferencesAndSync(): Promise<void> {
7 const state = reduxStore.getState().preferences
8 const isDirty = JSON.stringify(state.current) !== JSON.stringify(state.original)
9 if (!isDirty) return
10
11 let confirmedRow: any
12 try {
13 confirmedRow = await updateUserPreferences(preferencesToRow(state.current))
14 } catch (err) {
15 console.warn('[preferences] Save failed:', err)
16 return
17 }
18
19 await flux.dispatchLocalUpdate('preferences', confirmedRow ?? preferencesToRow(state.current))
20}
Confirmed writes only

Call dispatchLocalUpdate only once the server has accepted the mutation — it persists to IDB and mirrors to other tabs immediately, so it is a confirmed-write dispatch, not an optimistic one.

9

Redux vs. other state managers

Redux is one of two state managers that benefit from an explicit adapter factory — the other being Jotai.
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// Stunk — createStunkStoreAdapter(chunk)
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.
Immer-Safe Mutation Pathways

Because Redux Toolkit employs Immer under the hood, action handlers triggered by itemsAdapter can perform structural updates without manual object spread boilerplate. Flux guarantees payloads dispatched into reducers conform strictly to plain JSON-serializable primitives.

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