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
1// A plain Stunk chunk — nothing Flux-specific about it yet.
2import { chunk } from 'stunk'
3
4interface Item {
5 id: string
6 name: string
7 description: string
8 status: string
9 price: number
10}
11
12// A Chunk is Stunk's atomic unit of state — .get() / .set() / .subscribe().
13// Unlike Zustand, Redux, or Jotai, Stunk has no single top-level store
14// object at all; state lives in independent chunk instances. Create one
15// chunk per piece of state you want Flux to own.
16export 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})
12
13// createStunkStoreAdapter takes exactly ONE chunk — wrapping chunk.get()
14// and chunk.set(data) to satisfy Flux's { getState, setState } contract.
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 — so dispatchToStore's fallback path
3// (store.setState(data)) has nothing to call on a bare chunk without
4// a bridge:
5
6// hydrateState(store, data) if provided
7// -> else store.setState(data) — this is what the adapter exists
8// to make possible on top of chunk.set()
9// -> else warn and return
10
11// createStunkStoreAdapter wraps chunk.set(data) / chunk.get() behind
12// a { getState, setState } contract so dispatchToStore's fallback
13// path works on Stunk exactly as it does 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
1// When your realtime backend streams single delta frames (CREATE,
2// sparse UPDATE, or DELETE) alongside full array hydration payloads,
3// write a custom Stunk adapter or use hydrateState to handle both:
4
5import { chunk, batch } from 'stunk'
6
7export const stunkItemsAdapter = {
8 getState: () => itemsChunk.get(),
9 setState: (data: any) => {
10 if (!data) return
11
12 // ── 1. Array payload — bootstrap, IDB hydration, or full sync ──
13 if (Array.isArray(data)) {
14 itemsChunk.set([...data])
15 return
16 }
17
18 // ── 2. Object payload — single delta frame from live stream ────
19 if (typeof data === 'object') {
20 const current = itemsChunk.get()
21
22 // DELETE frame
23 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
24 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
25 if (idToRemove) {
26 itemsChunk.set(current.filter((item) => item.id !== idToRemove))
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 existingIdx = current.findIndex((item) => item.id === recordId)
37
38 if (existingIdx !== -1) {
39 // Sparse UPDATE — shallow merge fields onto existing record
40 const next = [...current]
41 next[existingIdx] = { ...next[existingIdx], ...rawRecord, id: recordId }
42 itemsChunk.set(next)
43 } else {
44 // CREATE — prepend new record
45 const normalized = { id: recordId, ...rawRecord } as Item
46 itemsChunk.set([normalized, ...current])
47 }
48 }
49 },
50}
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
1// createStunkStoreAdapter(chunk) only ever wraps ONE chunk. Its
2// setState() makes exactly one chunk.set() call, every time — you
3// cannot pass it two chunks; the signature only accepts one.
4//
5// If one channel needs to fan out to more than one chunk (e.g. a
6// derived count alongside the list), reach for hydrateState instead:
7
8import { chunk, batch } from 'stunk'
9import { createStunkStoreAdapter } from '@tsworldtech/flux'
10
11const itemsChunk = chunk<Item[]>([])
12const itemsCountChunk = chunk<number>(0)
13
14flux.register({
15 store: createStunkStoreAdapter(itemsChunk),
16 channel: 'items',
17 idbKey: 'items:all',
18 ttl: 'long',
19
20 // diffBeforeUpdate intentionally omitted here — the diff check
21 // only ever sees itemsChunk, so it can short-circuit and skip
22 // hydrateState even when itemsCountChunk genuinely needs updating.
23 hydrateState: (_store, data: Item[]) => {
24 // batch() imported directly from 'stunk' collapses multiple
25 // chunk writes into ONE atomic notification cycle.
26 batch(() => {
27 itemsChunk.set(data)
28 itemsCountChunk.set(data.length)
29 })
30 },
31})
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// Why batch() matters here specifically: each chunk.set() call
2// notifies its own subscribers independently by default. An
3// unbatched two-chunk hydrateState fires two separate notification
4// cycles — two re-renders — for what Flux treats internally as one
5// atomic dispatch of one payload under one generation number.
6//
7// batch() (imported directly from 'stunk') collapses that back down
8// to a single notification, regardless of whether the dispatch came
9// from a cold IDB boot, a bootstrap call, a realtime event, a
10// cross-tab mirror, or an optimistic rollback replay — all five
11// paths call the same hydrateState function, so wrapping it once
12// here covers all of them.
This is a Stunk-specific consideration

Redux and Jotai rarely hit this problem in practice, since one store object can hold multiple slices/atoms internally and update them together from inside its own logic. Stunk's atomic, one-chunk-per-piece-of-state design is what makes both the fan-out pattern and the batch() wrapping worth calling out here specifically — it requires zero changes to the core engine to use this way.

7

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
1// scope works identically on a Stunk 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: createStunkStoreAdapter(billingChunk),
7 channel: 'billing',
8 event: 'UPDATE',
9 idbKey: 'billing:latest',
10 ttl: 'short',
11 scope: 'user',
12 ingestionType: 'SNAPSHOT',
13})
8

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
9
10// All four combine identically with scope, ttl, ingestionType,
11// and every other registration field — the adapter layer is
12// 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 Jotai, see state-managers/jotai. For every other registration field alongside store, see api/register.