State Managers

Stunk

Stunk integrates through createStunkStoreAdapter — a thin bridge around exactly one chunk, so dispatchToStore's setState fallback has something concrete to call. No top-level store object involved at all.

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

A plain Stunk chunk

Nothing Flux-specific goes into the chunk yet. Stunk has no overarching store object the way Zustand has a hook or Redux has a store — state lives in independent chunk instances, each with its own get, set, and subscribe methods.
typescript
1import { chunk } from 'stunk'
2
3interface Item {
4 id: string
5 name: string
6 description: string
7 status: string
8 price: number
9}
10
11export const itemsChunk = chunk<Item[]>([])
2

Registering with createStunkStoreAdapter

Wrap the chunk with createStunkStoreAdapter and pass the result as store — the adapter takes a single chunk, nothing else.
typescript
1import { createStunkStoreAdapter } from '@tsworldtech/flux'
2import { itemsChunk } from './itemsChunk'
3
4flux.register({
5 store: createStunkStoreAdapter(itemsChunk),
6 channel: 'items',
7 event: 'UPDATE',
8 idbKey: 'items:all',
9 ttl: 'long',
10 ingestionType: 'COLLECTION_ALL',
11})
3

Why Stunk needs an adapter

A chunk's real methods are .get(), .set(), and .subscribe() — not setState. The adapter exists purely to give dispatchToStore's fallback path something to call.
typescript
1// A chunk's real API is .get() / .set() / .subscribe(), not
2// setState/getState — createStunkStoreAdapter wraps chunk.set(data) /
3// chunk.get() behind a { getState, setState } contract so
4// dispatchToStore's fallback path works on Stunk exactly as it does
5// on Zustand.
4

Handling sparse updates & live delta events

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 update the chunk appropriately:
typescript
1import { chunk, batch } from 'stunk'
2
3export const stunkItemsAdapter = {
4 getState: () => itemsChunk.get(),
5 setState: (data: any) => {
6 if (!data) return
7
8 if (Array.isArray(data)) {
9 itemsChunk.set([...data])
10 return
11 }
12
13 if (typeof data === 'object') {
14 const current = itemsChunk.get()
15
16 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
17 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
18 if (idToRemove) {
19 itemsChunk.set(current.filter((item) => item.id !== idToRemove))
20 }
21 return
22 }
23
24 const rawRecord = data.data ?? data.new ?? data
25 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
26 if (!recordId) return
27
28 const existingIdx = current.findIndex((item) => item.id === recordId)
29
30 if (existingIdx !== -1) {
31 const next = [...current]
32 next[existingIdx] = { ...next[existingIdx], ...rawRecord, id: recordId }
33 itemsChunk.set(next)
34 } else {
35 const normalized = { id: recordId, ...rawRecord } as Item
36 itemsChunk.set([normalized, ...current])
37 }
38 }
39 },
40}
5

One adapter, one chunk — fanning out to more with batch()

createStunkStoreAdapter only ever wraps one chunk. The moment a single channel needs to update more than one — a list plus a derived count, for example — reach for hydrateState along with Stunk's native batch() helper.
typescript
1import { chunk, batch } from 'stunk'
2import { createStunkStoreAdapter } from '@tsworldtech/flux'
3
4const itemsChunk = chunk<Item[]>([])
5const itemsCountChunk = chunk<number>(0)
6
7flux.register({
8 store: createStunkStoreAdapter(itemsChunk),
9 channel: 'items',
10 idbKey: 'items:all',
11 ttl: 'long',
12 hydrateState: (_store, data: Item[]) => {
13 batch(() => {
14 itemsChunk.set(data)
15 itemsCountChunk.set(data.length)
16 })
17 },
18})
6

Always wrap multi-chunk writes in batch()

Every chunk.set() call notifies its own subscribers independently by default — wrapping a fan-out in Stunk's own batch() collapses that back into a single notification cycle.
typescript
1// batch() (imported directly from 'stunk') collapses multiple
2// chunk.set() calls into a single notification cycle, regardless of
3// whether the dispatch came from cold boot, bootstrap, realtime, a
4// cross-tab mirror, or a local write.
7

Deleting a record: online vs offline

Step 4's adapter already routes an incoming DELETE frame into the chunk. The other half is triggering that delete from the UI in a way that survives being offline.
typescript
1// lib/flux.ts (excerpt) — deletion queue setup for the items channel.
2import { addQueueEntrySafe, getAllQueueEntries, removeQueueEntry } from '@tsworldtech/flux'
3import { itemsChunk } from './itemsChunk'
4
5const ITEMS_QUEUE_STORE = 'items_mutation_queue'
6
7async function deleteItemDirect(id: string): Promise<{ ok: boolean; error?: string }> {
8 try {
9 const res = await fetch('/api/items', {
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 queueItemDelete(id: string): Promise<void> {
22 await addQueueEntrySafe(
23 {
24 channel: 'items',
25 storeName: ITEMS_QUEUE_STORE,
26 endpoint: '/api/items',
27 method: 'DELETE',
28 payload: { id },
29 timestamp: Date.now(),
30 dedupeKey: `item:${id}`,
31 dedupeStrategy: 'replace',
32 } as any,
33 'MyApp',
34 )
35}
36
37async function purgePendingCreatesForItem(id: string): Promise<void> {
38 const entries = await getAllQueueEntries('MyApp', ITEMS_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', ITEMS_QUEUE_STORE)
44 }
45 }
46}
typescript
1// lib/flux.ts (excerpt) — optimistic chunk removal first, then direct
2// or queued network confirmation.
3export async function deleteItemAndSync(
4 id: string
5): Promise<{ error: Error | null; queued: boolean }> {
6 const current = itemsChunk.get()
7 itemsChunk.set(current.filter((item) => item.id !== 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// stunkItemsAdapter.setState (from the sparse-delta step) already
2// handles DELETE frames. Register with queueConfig and an
3// onReplaySuccess that confirms the delete once it lands.
4flux.register({
5 store: stunkItemsAdapter,
6 channel: 'items',
7 table: 'items',
8 idbKey: 'items:all',
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

A record created offline and never replayed has no server row to delete — remove it from the chunk and purge the pending create instead of queuing a DELETE that would 404.

8

Combining with scope

Stunk 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: createStunkStoreAdapter(billingChunk),
3 channel: 'billing',
4 event: 'UPDATE',
5 idbKey: 'billing:latest',
6 ttl: 'short',
7 scope: 'user',
8 ingestionType: 'SNAPSHOT',
9})
9

Local writes: dispatchLocalUpdate()

A confirmed local mutation doesn't need to wait for the realtime layer to catch up. flux.dispatchLocalUpdate(channel, data, options?) routes a server-confirmed write through the exact same hydrateState — and so the same batched chunk.set() calls — that bootstrap and realtime frames already use.
typescript
1import { chunk } from 'stunk'
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 preferencesChunk = chunk<UserPreferences>(defaultPreferences)
14export const originalPreferencesChunk = chunk<UserPreferences | null>(null)
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 { theme: prefs.theme, locale: prefs.locale, digest_frequency: prefs.digestFrequency }
27}
typescript
1import { batch } from 'stunk'
2import { preferencesChunk, originalPreferencesChunk } from './preferencesChunk'
3import type { UserPreferences } from './preferencesChunk'
4
5function normalizeRow(row: any, fallback: UserPreferences): UserPreferences {
6 if (!row) return fallback
7 return {
8 theme: row.theme ?? fallback.theme,
9 locale: row.locale ?? fallback.locale,
10 digestFrequency: row.digest_frequency ?? fallback.digestFrequency,
11 }
12}
13
14flux.register({
15 channel: 'preferences',
16 event: 'UPDATE',
17 idbKey: 'preferences:settings',
18 ttl: 'medium',
19 scope: 'user',
20 ingestionType: 'SNAPSHOT',
21 store: createStunkStoreAdapter(preferencesChunk),
22 hydrateState: (_store, data: any) => {
23 if (!data) return
24 const row = Array.isArray(data) ? data[0] : (data.data ?? data.new ?? data)
25 const normalized = normalizeRow(row, preferencesChunk.get())
26
27 batch(() => {
28 preferencesChunk.set(normalized)
29 originalPreferencesChunk.set(normalized)
30 })
31 },
32})
typescript
1import { preferencesChunk, originalPreferencesChunk, preferencesToRow } from './preferencesChunk'
2import { updateUserPreferences } from './actions/preferences'
3
4export async function savePreferencesAndSync(): Promise<void> {
5 const current = preferencesChunk.get()
6 const original = originalPreferencesChunk.get()
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

Call dispatchLocalUpdate only once your backend has accepted the write — it persists to IDB and mirrors to other open tabs immediately, so it's a confirmed-write dispatch, never an optimistic one.

10

Stunk vs. other state managers

Stunk is one of three state managers that use an explicit adapter factory, alongside Redux and 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
6// Jotai — createJotaiStoreAdapter(atom, store) or custom adapter
7// Stunk — createStunkStoreAdapter(chunk) or custom adapter
8// Other — any state manager reachable via a custom hydrateState
Granular Reactive Subscription

Because individual Stunk chunks operate independently, updating a chunk through createStunkStoreAdapter triggers execution only for components directly bound to that chunk's subscription lifecycle, avoiding widespread virtual DOM diffing across the rest of the application.

For Zustand, Flux's native option, see state_managers/zustand. For Redux, see state_managers/redux. For Jotai, see state_managers/jotai. For the full offline write queue, see core_concepts/offline_queue. For every other registration field alongside store, see api/register.

Powered by Flux