Getting Started

React

This guide takes a fresh Vite + React project to a fully offline resilient, single bootstrap, realtime synced app — using whichever state manager you already work with.

Package manager
Language
Prerequisites
  • An existing Vite + React project.
  • (Optional)A backend you can subscribe to — Supabase, or any server that can speak Socket.io, SSE, or raw WebSocket.
  • Node 18+.
1

Install the core package

bash
npm install @tsworldtech/flux
This is the framework agnostic engine — IDB caching, the offline queue, the mutation pipeline, the license system, job tracking. Every other package below is a thin adapter on top of this one.
2

Install the React integration

bash
npm install @tsworldtech/flux-react
This gives you FluxProvider, HydrationGate, FluxErrorBoundary, fluxVitePlugin (the Vite plugin that injects your service worker at build time), and the useFlux() / useFluxTime() hooks — the same hooks flux-next ships, minus anything Next.js specific like RSC handling.
3

Choose your realtime adapter

bash
npm install @tsworldtech/flux-supabase

createPollingAdapter() still gets you full IDB caching, the offline queue, and the SW layer — you're only trading live push updates for a polling interval. Swap in a real adapter later with no changes to your register() calls. If you have no backend at all — static marketing pages or fully hardcoded data — choose the **No realtime backend** option above and use createNoopAdapter() instead. It disables the realtime layer entirely while keeping the rest of the stack.

4

Define your precache route manifest

Centralize the offline application shell routes in a dedicated config file. Flux uses this list to warm the service worker cache on first load and guarantee instant offline navigation across your primary UI sections. Keep this file to static, hand picked routes only — Remix's dynamic route shell documents are generated separately, see Step 5 and Step 13.
src/config/precache-routes.ts
1// src/config/precache-routes.ts
2//
3// Static, hand picked routes only. If you're on Remix with dynamicRouteShapes
4// configured (see Step 5), the __flux_shell__ sentinel URLs it produces are
5// NOT listed here — they're derived automatically and folded into the SW's
6// precache list, so don't add them to this file by hand.
7export const PRECACHE_ROUTES = [
8 '/',
9 '/docs',
10 '/docs/getting-started',
11 '/dashboard',
12 '/dashboard/analytics',
13 '/dashboard/settings',
14 '/offline',
15] as const;
5

Configure the Vite plugin

vite.config.ts
1// vite.config.ts
2import { defineConfig } from 'vite'
3import react from '@vitejs/plugin-react'
4import { fluxVitePlugin } from '@tsworldtech/flux-react/plugin'
5import { PRECACHE_ROUTES } from './src/config/precache-routes'
6
7export default defineConfig({
8 plugins: [
9 react(),
10
11 // Reads dist/.vite/manifest.json in closeBundle and bakes the SW
12 // template with your build's actual chunk hashes.
13 fluxVitePlugin({
14 // Routes to precache into the service worker on first load
15 precacheRoutes: [...PRECACHE_ROUTES],
16
17 // The fallback route when a page is neither cached nor reachable
18 offlineShell: '/offline',
19
20 // Namespaces every IDB store and SW cache this app creates.
21 // Must match the storagePrefix you pass to createFlux().
22 storagePrefix: 'my_react_app',
23
24 // Names for the SW's background sync tags used by the queue
25 syncTags: ['flux:sync:default'],
26
27 // Dynamic route offline coverage
28 // dynamicRoutePrefixes: which URL prefixes are dynamic detail routes
29 // at all. When a hard navigation under one of these prefixes misses
30 // both the page cache and the network, the SW falls back instead of
31 // going straight to the offline error page.
32 dynamicRoutePrefixes: ['/dashboard/items/'],
33
34 // isSpa: not set here on purpose. It defaults to true for this
35 // package, and fluxVitePlugin auto detects Remix (a '@remix-run/*'
36 // dependency in package.json) and flips the default to false for
37 // you — logging a line confirming it. You only need to set this
38 // by hand if you're running Remix's own SPA mode
39 // (unstable_ssr: false), which can't be detected from package.json.
40 // See the callout below and the Remix example after this block.
41
42 // dynamicRouteShapes and dynamicRouteIdbKeys: intentionally absent
43 // here. In plain Vite/CRA there's exactly one HTML document for
44 // every route, so there's nothing distinct to precache per subpath,
45 // and the SW serves that one document without checking IDB.
46 // dynamicRoutePrefixes alone already covers every subpath under a
47 // dynamic route. See the callout below and Step 13.
48
49 // Route prefixes that bypass the service worker completely (auth callbacks, webhooks)
50 bypassRoutePrefixes: ['/auth/callback'],
51 }),
52 ],
53 server: {
54 port: 5173,
55 },
56})
Unlike withFlux() in Next.js, this plugin has no server rendered build manifest to read for dynamic route chunk warming — Vite emits a client only dist/.vite/manifest.json, and fluxVitePlugin reads it in closeBundle to bake chunk hashes into the generated service worker.
isSpa, dynamicRouteShapes and dynamicRouteIdbKeys — what actually changes between Vite/CRA and Remix

In a plain Vite SPA or CRA there is exactly one HTML document for every route. That means dynamicRoutePrefixes on its own already gets a dynamic route — and everything under it, tabs and nested subpages included — working offline: a hard miss just serves index.html and your client side router mounts the right screen from window.location, same as it does online. dynamicRouteShapes has nothing to add there, because there is no second document to precache, and dynamicRouteIdbKeys is never consulted, because the worker serves the one document without checking IDB first.

Remix is different: each route genuinely is its own server rendered document, so dynamicRouteShapes becomes meaningful there — see Step 13 — and so does dynamicRouteIdbKeys, which tells the worker where to look to confirm it has data for a slug before serving a cached shell. fluxVitePlugin resolves which mode you're in automatically by checking your package.json for a @remix-run/* dependency and defaulting isSpa accordingly — you only need to set isSpa by hand if you're running Remix's own SPA mode (unstable_ssr: false), which isn't detectable from package.json alone. An explicit value you set always overrides the detected default.

On Remix, the config looks the same shape as above with three additions:
vite.config.ts (Remix)
1// vite.config.ts — Remix app, auto detected as isSpa: false
2import { defineConfig } from 'vite'
3import { vitePlugin as remix } from '@remix-run/dev'
4import { fluxVitePlugin } from '@tsworldtech/flux-react/plugin'
5import { PRECACHE_ROUTES } from './app/config/precache-routes'
6
7export default defineConfig({
8 plugins: [
9 remix(),
10
11 fluxVitePlugin({
12 precacheRoutes: [...PRECACHE_ROUTES],
13 offlineShell: '/offline',
14 storagePrefix: 'my_remix_app',
15 syncTags: ['flux:sync:default'],
16
17 // isSpa is left unset on purpose — fluxVitePlugin finds the
18 // '@remix-run/dev' dependency in package.json and defaults isSpa to
19 // false for you. Pass isSpa: true explicitly only if this app runs
20 // Remix's own SPA mode (unstable_ssr: false).
21
22 // Same meaning as the plain Vite case: which URL prefixes are
23 // dynamic detail routes at all.
24 dynamicRoutePrefixes: ['/items/'],
25
26 // Now meaningful, because isSpa resolved to false: Remix renders a
27 // real, distinct document per route, so this detail route gets its
28 // own precached shell instead of falling back to a generic one.
29 // subpaths: [''] means just the bare detail page — see Step 13 for
30 // a route with tabs/subpages.
31 dynamicRouteShapes: [
32 {
33 prefix: '/items/',
34 subpaths: [''],
35 },
36 ],
37
38 // Route prefix -> where the SW looks to decide "do we have data for
39 // this route offline?". Only used when isSpa is false: the SW only
40 // serves a cached shell if it also finds data for that slug,
41 // otherwise the user would land on an empty page. Without this the
42 // check falls back to a slow full scan of IndexedDB. Keys must match
43 // dynamicRoutePrefixes exactly (trailing slash included).
44 //
45 // - idbKey: the idbKey of the per-item detail (LRU) channel you
46 // pass to flux.register(). Tried first, as an exact
47 // key get: '{scope}:{idbKey}:{slug}'.
48 // - listKey: the idbKey of the whole-list channel. Fallback used
49 // when the URL slug isn't the key the detail entry is
50 // stored under. One get on the list, then an
51 // in-memory match.
52 // - listFields: item fields compared against the URL slug.
53 //
54 // These strings must match your flux.register() idbKey values
55 // exactly. A plain string ('/items/': 'item_detail') also works
56 // when the slug is always the detail key.
57 dynamicRouteIdbKeys: {
58 '/items/': {
59 idbKey: 'item_detail',
60 listKey: 'items:list',
61 listFields: ['slug', 'id'],
62 },
63 },
64
65 bypassRoutePrefixes: ['/auth/callback'],
66 }),
67 ],
68})
Keep these strings in sync with flux.register()

idbKey and listKey must be exactly the idbKey values you pass to flux.register() for the detail and list channels, and the keys of dynamicRouteIdbKeys must match dynamicRoutePrefixes exactly, trailing slash included. If they drift apart the service worker looks in the wrong place and refuses to open a route that has perfectly good cached data — with no error.

6

Add your manifest.json

Place this file at public/manifest.json. Flux's service worker reads it during installation to configure the PWA shell. This step is required for every app — static sites included — because the SW uses the manifest icons and theme colors for the offline splash.
public/manifest.json
1{
2 "name": "Flux React App",
3 "short_name": "FluxApp",
4 "description": "Offline first realtime web application.",
5 "start_url": "/",
6 "display": "standalone",
7 "background_color": "#0B1519",
8 "theme_color": "#156A80",
9 "icons": [
10 { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
11 { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }
12 ]
13}
7

Define your types (TypeScript only)

Optional, but recommended: keep your registered data shapes in one place per store, imported by both your state manager file (Step 8) and your engine file (Step 9) rather than declared inline in either. JavaScript users can skip straight to Step 8.
src/types/flux.ts
1// src/types/flux.ts
2export interface DashboardMetric {
3 id: string
4 label: string
5 value: string
6 status: 'operational' | 'syncing' | 'offline'
7}
8

Create your state store

The one non negotiable contract, regardless of which state manager you pick: whatever you pass as store on register() needs a setState(data) method with this exact signature, because dispatchToStore calls it directly on every IDB cold boot read, every bootstrap dispatch, every realtime event — and, as of Step 16, every confirmed local write too.

Zustand — native, no adapter needed

src/store/dashboardStore.ts
1// src/store/dashboardStore.ts
2import { create } from 'zustand'
3import type { DashboardMetric } from '../types/flux'
4
5interface DashboardStore {
6 metrics: DashboardMetric[]
7 isHydrated: boolean
8 setState: (data: any) => void
9}
10
11export const useDashboardStore = create<DashboardStore>((set, get) => ({
12 metrics: [],
13 isHydrated: false,
14 setState: (data: any) => {
15 if (!data) return
16
17 // 1. Array payload — bootstrap, IDB hydration, or full sync
18 if (Array.isArray(data)) {
19 set({ metrics: [...data], isHydrated: true })
20 return
21 }
22
23 // 2. Object payload — single delta frame from live stream
24 if (typeof data === 'object') {
25 const current = get().metrics
26
27 // DELETE
28 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
29 const id = String(data.id ?? data._id ?? data.data?.id ?? '')
30 if (id) set({ metrics: current.filter((m) => m.id !== id) })
31 return
32 }
33
34 const rawRecord = data.data ?? data.new ?? data
35 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
36
37 if (!recordId) return
38
39 const idx = current.findIndex((m) => m.id === recordId)
40 if (idx !== -1) {
41 // Sparse UPDATE — shallow merge onto existing record
42 const next = [...current]
43 next[idx] = { ...next[idx], ...rawRecord, id: recordId }
44 set({ metrics: next, isHydrated: true })
45 } else {
46 // CREATE — prepend new record
47 set({
48 metrics: [{ id: recordId, ...rawRecord }, ...current],
49 isHydrated: true,
50 })
51 }
52 }
53 },
54}))

Redux Toolkit

src/store/itemsSlice.ts
1// src/store/itemsSlice.ts
2import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
3
4export interface Item {
5 id: string
6 name: string
7 description: string
8 status: string
9 price: number
10 createdAt: string
11 updatedAt: string
12}
13
14interface ItemsState {
15 list: Item[]
16}
17
18const initialState: ItemsState = { list: [] }
19
20const itemsSlice = createSlice({
21 name: 'items',
22 initialState,
23 reducers: {
24 setItems(state, action: PayloadAction<Item[]>) {
25 state.list = Array.isArray(action.payload) ? action.payload : []
26 },
27 addOptimisticItem(state, action: PayloadAction<Item>) {
28 state.list.unshift(action.payload)
29 },
30 upsertItem(state, action: PayloadAction<Item>) {
31 const incoming = action.payload
32 const optimisticIdx = state.list.findIndex(
33 (item) =>
34 item.status === 'optimistic_pending' &&
35 item.name === incoming.name
36 )
37 if (optimisticIdx !== -1) {
38 state.list[optimisticIdx] = incoming
39 return
40 }
41 const existingIdx = state.list.findIndex((item) => item.id === incoming.id)
42 if (existingIdx !== -1) {
43 state.list[existingIdx] = { ...state.list[existingIdx], ...incoming }
44 return
45 }
46 state.list.unshift(incoming)
47 },
48 removeItem(state, action: PayloadAction<string>) {
49 state.list = state.list.filter((item) => item.id !== action.payload)
50 },
51 },
52})
53
54export const { setItems, addOptimisticItem, upsertItem, removeItem } = itemsSlice.actions
55export default itemsSlice.reducer
src/store/reduxStore.ts
1// src/store/reduxStore.ts
2import { configureStore } from '@reduxjs/toolkit'
3import itemsReducer from './itemsSlice'
4
5export const reduxStore = configureStore({
6 reducer: {
7 items: itemsReducer,
8 },
9})
10
11export type RootState = ReturnType<typeof reduxStore.getState>
12export type AppDispatch = typeof reduxStore.dispatch
Redux needs an adapter to handle single live delta frames, sparse merges, and optimistic placeholder resolution:
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — bridging Redux into Flux
2import { reduxStore } from '../store/reduxStore'
3import { setItems, upsertItem, removeItem, type Item } from '../store/itemsSlice'
4
5export const itemsAdapter = {
6 getState: () => reduxStore.getState().items.list,
7 setState: (data: any) => {
8 if (!data) return
9
10 if (Array.isArray(data)) {
11 const current = reduxStore.getState().items.list
12 const hasOptimistic = current.some((i) => i.status === 'optimistic_pending')
13
14 if (hasOptimistic) {
15 const resolved = data.map((item) => {
16 if (item.status !== 'optimistic_pending') return item
17 const confirmed = data.find(
18 (i) => i.status !== 'optimistic_pending' && i.name === item.name
19 )
20 return confirmed ? null : item
21 }).filter(Boolean) as Item[]
22
23 reduxStore.dispatch(setItems(resolved))
24 return
25 }
26
27 reduxStore.dispatch(setItems(data))
28 return
29 }
30
31 if (typeof data === 'object') {
32 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
33 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
34 if (idToRemove) reduxStore.dispatch(removeItem(idToRemove))
35 return
36 }
37
38 const rawRecord = data.data ?? data.new ?? data
39 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
40
41 if (recordId) {
42 const normalizedItem = { id: recordId, ...rawRecord } as Item
43 reduxStore.dispatch(upsertItem(normalizedItem))
44 }
45 }
46 },
47};

Jotai

src/store/notificationsAtom.ts
1// src/store/notificationsAtom.ts
2import { atom, createStore } from 'jotai'
3
4export interface Notification {
5 id: string
6 title: string
7 message: string
8 type: string
9 read: boolean
10 createdAt: string
11}
12
13export const notificationsAtom = atom<Notification[]>([])
14export const jotaiStore = createStore()
Jotai atoms require a standalone createStore() since Flux dispatches outside of the React render tree:
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — bridging Jotai into Flux
2import { notificationsAtom, jotaiStore, type Notification } from '../store/notificationsAtom'
3
4export const notificationsAdapter = {
5 getState: () => jotaiStore.get(notificationsAtom),
6 setState: (data: any) => {
7 if (!data) return
8
9 if (Array.isArray(data)) {
10 jotaiStore.set(notificationsAtom, data as Notification[])
11 return
12 }
13
14 if (typeof data === 'object') {
15 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
16 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
17 if (idToRemove) {
18 const current = jotaiStore.get(notificationsAtom)
19 jotaiStore.set(notificationsAtom, current.filter((n) => n.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 current = jotaiStore.get(notificationsAtom)
29 const existingIdx = current.findIndex((n) => n.id === recordId)
30
31 if (existingIdx !== -1) {
32 const merged = [...current]
33 merged[existingIdx] = { ...merged[existingIdx], ...rawRecord, id: recordId }
34 jotaiStore.set(notificationsAtom, merged)
35 } else {
36 const normalized = { id: recordId, ...rawRecord } as Notification
37 jotaiStore.set(notificationsAtom, [normalized, ...current])
38 }
39 }
40 },
41};

Stunk

src/store/itemsChunk.ts
1// src/store/itemsChunk.ts
2import { chunk } from 'stunk'
3
4export interface Item {
5 id: string
6 name: string
7 description: string
8 status: string
9 price: number
10}
11
12export const itemsChunk = chunk<Item[]>([])
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — bridging Stunk into Flux
2import { createStunkStoreAdapter } from '@tsworldtech/flux'
3import { itemsChunk } from '../store/itemsChunk'
4
5export const itemsAdapter = createStunkStoreAdapter(itemsChunk)
9

Create the Flux engine singleton

Vite modules only execute once per page load, so createFlux naturally acts as a singleton without dev singleton guards. Every tab below also exports ensureClusterBootOnce(), which starts the realtime layer — Step 11 shows where to call it.
bash
// src/lib/flux.ts
import { createFlux } from '@tsworldtech/flux'
import { createSupabaseAdapter } from '@tsworldtech/flux-supabase'
import { supabase } from './supabase-client'
import { useDashboardStore } from '../store/dashboardStore'
export const flux = createFlux({
adapter: createSupabaseAdapter(supabase),
storagePrefix: 'my_react_app',
ttl: {
short: 1000 * 60 * 5, // 5 minutes
medium: 1000 * 60 * 60, // 1 hour
long: 1000 * 60 * 60 * 24, // 24 hours
},
onStorageFallback: (layer, reason) => {
console.warn('[flux] Storage degraded to', layer, '—', reason)
},
})
// Starts leader election across open tabs and activates the realtime
// connection. Without it the engine is registered and IDB is populated,
// but the realtime manager stays inert: no leader is elected, no channel
// is opened, so live push AND the catch-up sync after a reconnect never
// fire. Guarded by a window flag so any number of callers trigger it
// only once per tab. Call it from your store initializer — see Step 11.
const CLUSTER_BOOT_KEY = '__flux_cluster_booted__'
declare global {
interface Window {
__flux_cluster_booted__?: boolean
}
}
export function ensureClusterBootOnce(): void {
if (typeof window === 'undefined') return
if (window[CLUSTER_BOOT_KEY]) return
window[CLUSTER_BOOT_KEY] = true
flux.realtime.ensureClusterBoot()
}
export const dashboardStoreAdapter = {
setState: (data: any) => useDashboardStore.getState().setState(data),
getState: () => useDashboardStore.getState().metrics,
}
flux.register({
channel: 'dashboard_metrics',
table: 'dashboard_metrics',
idbKey: 'dashboard_metrics:latest',
ttl: 'long',
ingestionType: 'COLLECTION_ALL',
diffBeforeUpdate: true,
event: 'UPDATE',
scope: 'global',
store: dashboardStoreAdapter,
})

Pick the tab matching the adapter you installed in Step 3. Vite modules execute once per page load, functioning naturally as singletons without Fast Refresh engine guards. Every tab exports ensureClusterBootOnce(), which starts the realtime layer — Step 11 shows where to call it. Note this only covers 'global' scoped registration — if you also register 'user' scoped channels, see the callout in Step 11 about registering them through registerUserChannels() rather than inline here.

10

Wrap your app

src/components/FluxClientWrapper.tsx
1// src/components/FluxClientWrapper.tsx
2import React from 'react'
3import { FluxProvider, HydrationGate, OfflineIndicator } from '@tsworldtech/flux-react'
4import { flux } from '../lib/flux'
5import { PRECACHE_ROUTES } from '../config/precache-routes'
6import { FluxStoreInitializer } from './storeInitializer'
7
8export function FluxClientWrapper({ children }: { children: React.ReactNode }) {
9 return (
10 <FluxProvider engine={flux} precacheRoutes={PRECACHE_ROUTES}>
11 <FluxStoreInitializer />
12 <HydrationGate>
13 {children}
14 </HydrationGate>
15 <OfflineIndicator />
16 </FluxProvider>
17 )
18}
11

The hydrate → bootstrap sequence

Configure the client side store initializer. Use the Global tab for public data that persists across user sessions, or the User (Auth Aware) tab for multi tenant state isolated per user account. Both end by starting the realtime cluster with ensureClusterBootOnce().
Don't forget ensureClusterBootOnce()

flux.bootstrap() only hydrates IndexedDB over a plain HTTP call — it never touches the realtime connection. Until ensureClusterBootOnce() runs, the realtime manager sits inert: channels are registered and the cache is full, but no leader tab is elected and no realtime channel is opened. Live push never fires, and neither does the catch-up sync after a reconnect — with no error. With several tabs open, Flux elects one leader to hold the connection and fans updates out to the rest, so booting the cluster is also what avoids one connection per tab. This applies to every adapter — Supabase, Socket.io, SSE and WebSocket alike. Call it after hydrate() for global data, or after a confirmed successful bootstrap for user scoped data, as shown below.

src/components/storeInitializer.tsx
1// src/components/storeInitializer.tsx (Global Scope)
2import { useEffect } from 'react'
3import { flux, dashboardStoreAdapter, ensureClusterBootOnce } from '../lib/flux'
4import { useDashboardStore } from '../store/dashboardStore'
5
6export function FluxStoreInitializer() {
7 useEffect(() => {
8 if (typeof window === 'undefined') return
9
10 const SESSION_KEY = 'flux_bootstrap_executed_session'
11
12 async function init() {
13 try {
14 // Step 1 — Always hydrate first from local IndexedDB (2000ms max timeout)
15 await flux.hydrate()
16
17 // Step 2 — Start leader election + realtime. The data is public,
18 // so there's nothing to wait for. Boot right after hydrate and
19 // BEFORE the early return below, so a tab that skips bootstrap
20 // (already seeded this session) still gets live updates.
21 ensureClusterBootOnce()
22
23 const isEmpty = useDashboardStore.getState().metrics.length === 0
24 if (isEmpty) sessionStorage.removeItem(SESSION_KEY)
25
26 if (!isEmpty && sessionStorage.getItem(SESSION_KEY) === 'true') {
27 return // already bootstrapped this session, disk is warm
28 }
29 sessionStorage.setItem(SESSION_KEY, 'true')
30
31 // Step 3 — Single network seed call for public app data
32 await flux.bootstrap({
33 endpoint: 'http://localhost:3001/api/bootstrap/public',
34 cooldownMs: 30_000,
35 scope: 'global',
36 map: {
37 dashboard_metrics: dashboardStoreAdapter,
38 },
39 })
40 } catch (err) {
41 console.warn('[FluxStoreInitializer] Failed:', err)
42 sessionStorage.removeItem(SESSION_KEY)
43 }
44 }
45
46 init()
47 }, [])
48
49 return null
50}
12

Your bootstrap API route (Node / Express)

The single backend endpoint that replaces fragmented network fetches on initial app load. Always return 200 HTTP responses with partial payloads when non critical query errors occur to allow Flux to ingest available data offline. Every registered channel's key should always be present in the payload — as null, {} or [] when there is nothing yet — so "no data yet" is cached as a correct offline state instead of a permanently missing partition.
server/routes/bootstrapPublic.ts
1// server/routes/bootstrapPublic.ts
2import express, { Router, Request, Response } from 'express'
3import Metric from '../models/Metric'
4
5const router: Router = express.Router()
6
7// GET /api/bootstrap/public
8router.get('/', async (req: Request, res: Response) => {
9 try {
10 const metrics = await Metric.find({})
11
12 res.json({
13 ok: true,
14 data: {
15 dashboard_metrics: metrics.map((m) => ({ ...m.toObject(), id: m._id.toString() })),
16 },
17 assets: [],
18 })
19 } catch (err: any) {
20 res.status(500).json({ ok: false, error: err.message })
21 }
22})
23
24export default router
server/server.ts
1// server/server.ts (excerpt)
2import bootstrapPublicRoute from './routes/bootstrapPublic'
3import bootstrapUserRoute from './routes/bootstrapUser'
4
5app.use('/api/bootstrap/public', bootstrapPublicRoute)
6app.use('/api/bootstrap/user', bootstrapUserRoute)
13

Dynamic detail pages that work offline (optional)

Only needed if you registered an LRU ingested channel (e.g. blog posts, product detail pages) and want an unvisited slug to still render offline after the one bootstrap call seeded it.
src/store/blogStore.ts
1// src/store/blogStore.ts
2import { create } from 'zustand'
3
4export interface BlogPost {
5 id: string
6 slug: string
7 title: string
8 excerpt: string
9 author: string
10 body: any[]
11}
12
13export type BlogCard = Omit<BlogPost, 'body'>
14
15interface BlogListStore {
16 posts: BlogCard[]
17 isHydrated: boolean
18 setState: (data: any) => void
19}
20
21export const useBlogListStore = create<BlogListStore>((set, get) => ({
22 posts: [],
23 isHydrated: false,
24 setState: (data: any) => {
25 if (!data) return
26 const incoming = Array.isArray(data) ? data : get().posts
27 set({ posts: incoming, isHydrated: true })
28 },
29}))
30
31interface BlogDetailStore {
32 activePost: BlogPost | null
33 isHydrated: boolean
34 setHydrated: () => void
35 setState: (data: any) => void
36}
37
38export const useBlogDetailStore = create<BlogDetailStore>((set) => ({
39 activePost: null,
40 isHydrated: false,
41 setHydrated: () => set({ isHydrated: true }),
42 setState: (data: any) => {
43 const post: BlogPost | null =
44 data && typeof data === 'object' && !Array.isArray(data) ? data : null
45 set({ activePost: post, isHydrated: true })
46 },
47}))
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — registering the list + detail channels
2import { useBlogListStore, useBlogDetailStore } from '../store/blogStore'
3
4export const blogListStoreAdapter = {
5 setState: (data: any) => useBlogListStore.getState().setState(data),
6 getState: () => useBlogListStore.getState().posts,
7}
8
9export const blogDetailStoreAdapter = {
10 setState: (data: any) => {
11 if (Array.isArray(data)) return
12 if (data && typeof data === 'object') {
13 useBlogDetailStore.getState().setState(data)
14 }
15 },
16 getState: () => {
17 const active = useBlogDetailStore.getState().activePost
18 return active ? [active] : []
19 },
20}
21
22flux.register({
23 channel: 'blog_posts',
24 table: 'blog_posts',
25 idbKey: 'blog_posts:list',
26 ttl: 'long',
27 ingestionType: 'COLLECTION_ALL',
28 diffBeforeUpdate: true,
29 event: 'UPDATE',
30 store: blogListStoreAdapter,
31})
32
33flux.register({
34 channel: 'blog_post_detail',
35 table: 'blog_posts',
36 idbKey: 'blog_post_detail',
37 ttl: 'long',
38 ingestionType: 'LRU',
39 cacheStrategy: {
40 type: 'LRU',
41 maxBytes: 15 * 1024 * 1024,
42 trackAccessTime: true,
43 },
44 store: blogDetailStoreAdapter,
45 event: 'UPDATE',
46})
src/pages/BlogSlugPage.tsx
1// src/pages/BlogSlugPage.tsx
2import { useEffect } from 'react'
3import { useParams, Link } from 'react-router-dom'
4import { idbGet } from '@tsworldtech/flux'
5import { useBlogDetailStore } from '../store/blogStore'
6
7export default function BlogSlugPage() {
8 const { slug } = useParams<{ slug: string }>()
9
10 const activePost = useBlogDetailStore((s) => s.activePost)
11 const isHydrated = useBlogDetailStore((s) => s.isHydrated)
12
13 useEffect(() => {
14 if (!slug) return
15
16 useBlogDetailStore.setState({ activePost: null, isHydrated: false })
17
18 const idbKey = `blog_post_detail:${slug}`
19 const storagePrefix = 'my_react_app'
20
21 idbGet(idbKey, storagePrefix, true).then((result) => {
22 if (result?.data) {
23 useBlogDetailStore.getState().setState(result.data)
24 } else {
25 useBlogDetailStore.getState().setHydrated()
26 }
27 }).catch(() => {
28 useBlogDetailStore.getState().setHydrated()
29 })
30 }, [slug])
31
32 if (!isHydrated) return <p>Reading from local cache…</p>
33
34 if (!activePost) {
35 return (
36 <div>
37 <p>Post not found — this slug was not in your local cache.</p>
38 <Link to="/blog">← Back to blog</Link>
39 </div>
40 )
41 }
42
43 return (
44 <article>
45 <h1>{activePost.title}</h1>
46 <p>{activePost.excerpt}</p>
47 </article>
48 )
49}
src/main.tsx (excerpt)
1// src/main.tsx (excerpt) — wiring router inside FluxClientWrapper
2import React from 'react'
3import ReactDOM from 'react-dom/client'
4import { createBrowserRouter, RouterProvider } from 'react-router-dom'
5import { FluxClientWrapper } from './components/FluxClientWrapper'
6import BlogPage from './pages/BlogPage'
7import BlogSlugPage from './pages/BlogSlugPage'
8import OfflinePage from './pages/OfflinePage'
9import App from './App'
10import './index.css'
11
12const router = createBrowserRouter([
13 { path: '/', element: <App /> },
14 { path: '/blog', element: <BlogPage /> },
15 { path: '/blog/:slug', element: <BlogSlugPage /> },
16 { path: '/offline', element: <OfflinePage /> },
17])
18
19ReactDOM.createRoot(document.getElementById('root')!).render(
20 <React.StrictMode>
21 <FluxClientWrapper>
22 <RouterProvider router={router} />
23 </FluxClientWrapper>
24 </React.StrictMode>
25)
A detail route with multiple offline navigable subpaths — tabs, nested sections, anything more than one page under the same dynamic segment — is where isSpa from Step 5 actually forks behavior. Pick whichever matches your setup:
Nothing extra to wire up

With isSpa at its default of true, the dynamicRoutePrefixes entry from Step 5 already covers every subpath under /dashboard/items/ — /dashboard/items/42, /dashboard/items/42/settings, anything. A hard miss on any of them serves the same cached index.html, and React Router or TanStack Router reads the real path from window.location and mounts the matching component, which then hydrates from IDB exactly like BlogSlugPage above. There is no shell document to configure per subpath because there is only ever the one document.

14

Your offline fallback page

src/pages/OfflinePage.tsx
1// src/pages/OfflinePage.tsx
2export default function OfflinePage() {
3 return (
4 <div>
5 <h1>You're offline</h1>
6 <p>Previously visited pages are still available. Anything you submit will sync automatically once you reconnect.</p>
7 </div>
8 )
9}
Must be a real route in your router, and must be listed in both precacheRoutes and offlineShell in your fluxVitePlugin() config (Step 5).
15

Offline ready static images (optional)

For static assets not managed through flux.register(), use CachedImg to store them in Flux's image IndexedDB cache.
src/components/Logo.tsx
1// src/components/Logo.tsx
2import { CachedImg } from '@tsworldtech/flux-react'
3
4export function Logo() {
5 return (
6 <CachedImg
7 src="/assets/logo.png"
8 alt="App Logo"
9 className="h-6 w-auto object-contain transition-transform duration-200 group-hover:scale-105"
10 />
11 )
12}
16

Sync your own writes instantly (dispatchLocalUpdate)

Every store so far reacts to data arriving from outside the client — IDB cold boot, bootstrap, or a realtime delta frame. But a locally originated write — a form submit, a settings change, anything the user just did — has to round trip through your backend and then wait for the realtime event to come back before the offline queue, IDB cache, and other open tabs catch up. flux.dispatchLocalUpdate(channel, data, options?) closes that gap: once your own mutation is confirmed, hand the result straight to Flux and it goes through the exact same intake gate as bootstrap and realtime data — the store's setState(), the scope aware IDB write, the generation barrier, and the cross tab mirror all apply exactly as they do for server originated data.
Signature

dispatchLocalUpdate(channel: string, data: any, options?: { persist?: boolean; broadcast?: boolean }) — persist (default true) controls whether the write lands in IDB, and broadcast (default true) controls whether it's mirrored to other open tabs. Only call this after your write is confirmed by the server — it's a confirmed write dispatch, not an optimistic one, so it should never carry unsaved local state.

The store — same setState() contract as any other channel

src/store/preferencesStore.ts
1// src/store/preferencesStore.ts
2import { create } from 'zustand'
3
4export interface UserPreferences {
5 theme: 'light' | 'dark'
6 locale: string
7 digestFrequency: 'daily' | 'weekly' | 'off'
8}
9
10const defaultPreferences: UserPreferences = {
11 theme: 'light',
12 locale: 'en-US',
13 digestFrequency: 'weekly',
14}
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
25// The inverse — turns confirmed store state back into a raw row shape
26// before it's handed to dispatchLocalUpdate(). Pure data shape
27// conversion, no Flux dependency, same convention as any other store.
28export function preferencesToRow(prefs: UserPreferences): Record<string, any> {
29 return {
30 theme: prefs.theme,
31 locale: prefs.locale,
32 digest_frequency: prefs.digestFrequency,
33 }
34}
35
36interface PreferencesStore {
37 preferences: UserPreferences
38 original: UserPreferences | null
39 setPreference: <K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => void
40 isDirty: () => boolean
41 setState: (data: any) => void
42}
43
44export const usePreferencesStore = create<PreferencesStore>((set, get) => ({
45 preferences: { ...defaultPreferences },
46 original: null,
47
48 setPreference: (key, value) => {
49 set((s) => ({ preferences: { ...s.preferences, [key]: value } }))
50 },
51
52 isDirty: () => {
53 const s = get()
54 return s.original ? JSON.stringify(s.preferences) !== JSON.stringify(s.original) : false
55 },
56
57 /* Flux intake gate — SNAPSHOT, scope: user
58 Same setState(data) contract every registered store needs — called
59 on IDB cold boot, on bootstrap, on every realtime delta frame, AND
60 now on a confirmed local write dispatched via dispatchLocalUpdate(). */
61 setState: (data: any) => {
62 if (!data) return
63
64 if (Array.isArray(data)) {
65 if (data.length === 0) return
66 const preferences = normalizeRow(data[0], get().preferences)
67 set({ preferences, original: { ...preferences } })
68 return
69 }
70
71 if (typeof data === 'object') {
72 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
73 set({ preferences: { ...defaultPreferences }, original: null })
74 return
75 }
76
77 const rawRecord = data.data ?? data.new ?? data
78 const preferences = normalizeRow(rawRecord, get().preferences)
79 set({ preferences, original: { ...preferences } })
80 }
81 },
82}))

Register the channel, like any other

src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — register the preferences channel
2import { usePreferencesStore } from '../store/preferencesStore'
3
4export const preferencesAdapter = {
5 setState: (data: any) => usePreferencesStore.getState().setState(data),
6 getState: () => {
7 const preferences = usePreferencesStore.getState().preferences
8 return preferences ? [preferences] : []
9 },
10}
11
12flux.register({
13 channel: 'preferences',
14 idbKey: 'preferences:settings',
15 ttl: 'medium',
16 ingestionType: 'SNAPSHOT',
17 diffBeforeUpdate: true,
18 event: 'UPDATE',
19 scope: 'user',
20 store: preferencesAdapter,
21})
Since this is a scope: 'user' channel, put it inside your registerUserChannels() function from Step 11 rather than at module top level — otherwise it falls into the same "empty after first logout" gap described there.

Wrap the save: write, confirm, then dispatch

Keep this wrapper in src/lib/flux.ts next to your other registrations — it's the one place that legitimately knows about both the engine and the store, so the store itself stays a plain store like every other example on this page:
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — save a local edit, then sync it into Flux
2import { usePreferencesStore, preferencesToRow } from '../store/preferencesStore'
3import { updateUserPreferences } from './actions/preferences' // your own API call
4
5export async function savePreferencesAndSync(): Promise<void> {
6 const before = usePreferencesStore.getState()
7 if (!before.isDirty()) return
8
9 // 1. Do the real write first. Nothing touches Flux until the server
10 // has actually confirmed the mutation — this is not an optimistic
11 // dispatch, it's a confirmed write dispatch.
12 let confirmedRow: any
13 try {
14 confirmedRow = await updateUserPreferences(preferencesToRow(before.preferences))
15 } catch (err) {
16 console.warn('[preferences] Save failed:', err)
17 return
18 }
19
20 // 2. Only now: push the confirmed row through the exact same intake
21 // gate bootstrap and realtime data go through. It updates the
22 // store (via preferencesAdapter → usePreferencesStore's own
23 // setState, same normalizeRow path as everything else), persists
24 // to this user's namespaced IDB key immediately, and mirrors the
25 // change to any other open tab — instead of waiting on the
26 // realtime UPDATE event to eventually catch up.
27 await flux.dispatchLocalUpdate('preferences', confirmedRow ?? preferencesToRow(before.preferences))
28}

Call it from your form

src/components/PreferencesForm.tsx
1// src/components/PreferencesForm.tsx (excerpt)
2import { usePreferencesStore } from '../store/preferencesStore'
3import { savePreferencesAndSync } from '../lib/flux'
4
5export function PreferencesForm() {
6 const { preferences, setPreference, isDirty } = usePreferencesStore()
7
8 return (
9 <form
10 onSubmit={(e) => {
11 e.preventDefault()
12 savePreferencesAndSync()
13 }}
14 >
15 <select
16 value={preferences.theme}
17 onChange={(e) => setPreference('theme', e.target.value as 'light' | 'dark')}
18 >
19 <option value="light">Light</option>
20 <option value="dark">Dark</option>
21 </select>
22
23 <button type="submit" disabled={!isDirty()}>
24 Save
25 </button>
26 </form>
27 )
28}
The same pattern applies to any channel — swap preferencesAdapter for any registered adapter (Redux, Jotai, or Stunk) and the save action for whatever your own mutation is. See each adapter's own page under State Managers for the equivalent local write wiring. If the write fails, skip the dispatch entirely and let your existing error handling surface it — there's nothing confirmed yet to push into Flux.

Deleting: dispatchLocalDelete for LRU detail entries

A list channel reaches other tabs as a diff patch, so dispatching the new list is enough to remove a row from it. But an LRU detail entry (Step 13) is a separate cached item that is not part of that diff. flux.dispatchLocalDelete(channel, id) removes it from IDB and from this tab, and signals every other open tab to drop it too. Skip it and a deleted item's detail page keeps rendering from cache.
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — confirmed deletion of a record that has an LRU detail entry
2import { useBlogListStore } from '../store/blogStore'
3
4export async function deletePostAndSync(id: string): Promise<{ error: Error | null }> {
5 // 1. Do the real delete first (your own API call).
6 const res = await fetch('/api/posts', {
7 method: 'DELETE',
8 headers: { 'Content-Type': 'application/json' },
9 body: JSON.stringify({ id }),
10 })
11 if (!res.ok) return { error: new Error('Delete failed (' + res.status + ')') }
12
13 // 2. Mirror the new list into the store + IDB. A list channel reaches
14 // other tabs as a diff patch.
15 const remaining = useBlogListStore.getState().posts.filter((p) => p.id !== id)
16 await flux.dispatchLocalUpdate('blog_posts', remaining)
17
18 // 3. The LRU detail entry is a SEPARATE cached item, not part of the
19 // list diff. dispatchLocalDelete removes it from IDB and this tab,
20 // and signals every other open tab to drop it too. Pass the same
21 // id the detail entry is stored under.
22 await flux.dispatchLocalDelete('blog_post_detail', id)
23
24 return { error: null }
25}
Just want a static page to work offline, no backend at all?

If you have no data to sync — a marketing page, a static docs page, anything with no register() calls — you don't need Steps 8 through 16 at all. Skip the engine, the adapter, the realtime cluster, and the store entirely. All you need is:

vite.config.ts
1// vite.config.ts — static offline precaching only
2import { defineConfig } from 'vite'
3import react from '@vitejs/plugin-react'
4import { fluxVitePlugin } from '@tsworldtech/flux-react/plugin'
5
6export default defineConfig({
7 plugins: [
8 react(),
9 fluxVitePlugin({
10 precacheRoutes: ['/', '/pricing', '/about', '/offline'],
11 offlineShell: '/offline',
12 storagePrefix: 'my_static_react_app',
13 bypassRoutePrefixes: ['/auth/callback'],
14 }),
15 ],
16})

Add every route you want available offline to precacheRoutes, plus your offline shell page from Step 14. The service worker generated by fluxVitePlugin() precaches these at build time regardless of whether createFlux() is called.

Every adapter shown here — Supabase, Socket.io, SSE, WebSocket, polling — and every state manager — Zustand, Redux, Jotai, Stunk — are interchangeable independently of each other and of the framework. Nothing in @tsworldtech/flux's core engine knows whether it's running inside Next.js, a plain Vite build, or Remix.

Powered by Flux