Migration Guides

From a hook-based server-state library

If your data fetching is built on the useQuery/useMutation pattern — a query-key cache, staleTime, refetchOnWindowFocus, invalidateQueries — this page covers what carries over almost unchanged, and the three gaps in that model that Flux exists to close: hard reload, offline, and multi-tab.

Prerequisites
  • An existing setup using a hook-based server-state / data-fetching library (query keys, mutations, cache invalidation)
  • A state manager Flux can write into — Zustand, Redux, or Jotai — see state-managers/zustand
  • Familiarity with api/register and api/bootstrap on the client side
1

The pattern, not the library

This isn't about one specific package — it's the shape nearly every hook-based server-state library converges on: a query key becomes a cache key, a mutation gets optimistic updates and invalidation. For a huge range of apps this is genuinely the right tool. This page is about what happens at the edges of that model.
typescript
1// The pattern this page is about — not any single library, but the
2// shape nearly every "server state" hook library converges on:
3// declare a query key, get caching, deduping, and refetch-on-focus
4// for free, declare a mutation, get optimistic updates and
5// invalidation for free.
6
7function ServicesList() {
8 const { data, isLoading } = useQuery({
9 queryKey: ['services'],
10 queryFn: () => fetch('/api/services').then(r => r.json()),
11 staleTime: 5 * 60 * 1000,
12 })
13
14 const mutation = useMutation({
15 mutationFn: (update) => fetch('/api/services/' + update.id, {
16 method: 'PATCH',
17 body: JSON.stringify(update),
18 }),
19 onMutate: async (update) => {
20 await queryClient.cancelQueries(['services'])
21 const prev = queryClient.getQueryData(['services'])
22 queryClient.setQueryData(['services'], (old) =>
23 old.map(s => s.id === update.id ? { ...s, ...update } : s)
24 )
25 return { prev }
26 },
27 onError: (err, update, context) => {
28 queryClient.setQueryData(['services'], context.prev)
29 },
30 onSettled: () => queryClient.invalidateQueries(['services']),
31 })
32
33 if (isLoading) return <Spinner />
34 return <List data={data} onEdit={mutation.mutate} />
35}
36
37// This works, and for a huge range of apps it's genuinely the right
38// tool. The gap this page is about is specifically what happens at
39// the edges of this model: reload, offline, and multi-tab.
2

Where the two models overlap almost exactly

A query key and a Flux channel are doing structurally the same job. This is not a rip-and-replace migration — most of the mental model transfers directly.
typescript
1// Where the two models overlap almost one-to-one — this is NOT a
2// "replace everything" migration, it's a "here's what changes and
3// why" migration. Query keys and Flux channels are doing structurally
4// the same job.
5
6// BEFORE
7useQuery({ queryKey: ['services'], queryFn: fetchServices })
8
9// AFTER
10flux.register({
11 store: servicesStore,
12 channel: 'services',
13 idbKey: 'services:list',
14 ttl: 'medium',
15})
16// data now lives in servicesStore (Zustand/Redux/Jotai — Section
17// 4.15), read via your state manager's own hook, not a Flux-specific
18// one. There is no useFluxQuery(channel) — the store IS the cache,
19// and your components already know how to read it.
3

Gap 1 — staleTime doesn't survive a reload

staleTime only helps within a session. A hard reload throws the whole in-memory cache away, every time, for every user.
typescript
1// The first real gap: staleTime controls how long data is
2// considered fresh WITHIN a session, but a hard reload throws
3// everything away — every hook-based server-state library's
4// in-memory cache dies with the tab. staleTime: 5 minutes still
5// means a full network refetch on every reload, every time, for
6// every user, forever.
7
8// BEFORE — fast the 2nd, 3rd, 4th time you view a page in the SAME
9// session, slow again on every literal page reload:
10useQuery({
11 queryKey: ['services'],
12 queryFn: fetchServices,
13 staleTime: 5 * 60 * 1000, // only helps until the tab reloads
14})
15
16// AFTER — ttl plays the same role staleTime does (how long before
17// data is considered stale enough to refetch), but the cache itself
18// is IndexedDB, not a JS object living in the tab's memory. A reload
19// hydrates instantly from disk instead of re-fetching, and only
20// refetches over the network once ttl has genuinely elapsed:
21flux.register({
22 store: servicesStore,
23 channel: 'services',
24 idbKey: 'services:list',
25 ttl: 'medium', // Section 4.1 — short/medium/long map to configured ms
26})
4

Gap 2 — refetchOnWindowFocus fires blind

It exists because there's no other freshness signal available. With a realtime adapter registered, most focus events resolve with zero network calls instead of one guaranteed refetch.
typescript
1// refetchOnWindowFocus is the second gap — it exists in hook-based
2// libraries precisely because there's no other freshness signal
3// available. It works, but it means every tab switch is a network
4// round trip, even when nothing changed.
5
6// BEFORE
7useQuery({
8 queryKey: ['services'],
9 queryFn: fetchServices,
10 refetchOnWindowFocus: true, // network call on every single focus
11})
12
13// AFTER — focus/visibility still triggers a staleness CHECK
14// (Section 4.13's revalidation loop), but if a realtime adapter is
15// registered, the loop holds instead of firing an HTTP refetch
16// immediately, giving the live channel a chance to prove the data is
17// already current. Most focus events resolve with ZERO network
18// calls once realtime is wired — only genuinely stale, unconfirmed
19// channels fall through to a refetch:
20const flux = createFlux({
21 adapter: createSupabaseAdapter(supabaseClient),
22})
23
24flux.register({
25 store: servicesStore,
26 channel: 'services',
27 event: 'UPDATE',
28 idbKey: 'services:list',
29 ttl: 'medium',
30})
31// no refetchOnWindowFocus flag anywhere — this behavior is
32// structural, not opted into per-query
5

Gap 3 — offline mutations have no home

This is the gap that matters most in practice. onError rolls back an optimistic update, but the user's edit is simply gone unless something persists and retries it.
typescript
1// The gap that matters most in practice: what happens to a mutation
2// fired while offline. Hook-based server-state libraries generally
3// have no answer to this beyond onError — the mutation fails, the
4// optimistic update rolls back, and the user's edit is gone unless
5// you build your own persistence layer on top.
6
7// BEFORE — offline mutation just... fails:
8const mutation = useMutation({
9 mutationFn: (update) => fetch('/api/services/' + update.id, {
10 method: 'PATCH',
11 body: JSON.stringify(update),
12 }),
13 onError: (err, update, context) => {
14 // rollback happens, but the user's edit is now just gone —
15 // there's no queue, nothing retries this automatically
16 queryClient.setQueryData(['services'], context.prev)
17 },
18})
19
20// ────────────────────────────────────────────────────────────────
21
22// AFTER — the mutation is queued to IndexedDB BEFORE any network
23// attempt (Section 4.5, Stage 3), survives a full page reload while
24// offline, and replays automatically the moment the connection
25// returns — no onError branch needed for the offline case at all:
26flux.register({
27 store: servicesStore,
28 channel: 'services',
29 idbKey: 'services:list',
30 ttl: 'medium',
31 optimistic: { rollbackOnError: true },
32 queueConfig: {
33 storeName: 'services-updates',
34 replayEndpoint: '/api/services/batch-update',
35 replayStrategy: 'handshake', // detects server-side conflicts
36 conflictStrategy: 'reject', // before overwriting on reconnect
37 },
38})
39
40// the mutation call itself
41flux.replay('services') // or let the automatic triggers handle it
42// (initial load, online event, focus, visibilitychange — Section 4.6)
6

Gap 4 — no cross-tab awareness

Two tabs on the same page hold two independent caches by default, with no communication between them unless it's built by hand.
typescript
1// The gap that's easy to miss until a user files a confusing bug
2// report: hook-based server-state libraries have no cross-tab
3// awareness by default. Two tabs open on the same page both hold
4// independent in-memory caches, both fetch independently, and an
5// optimistic update in one tab has no way to reach the other.
6
7// BEFORE — open the same page in two tabs, get two independent
8// fetches, two independent caches, no communication between them
9// unless you wire up a BroadcastChannel yourself:
10useQuery({ queryKey: ['services'], queryFn: fetchServices })
11// tab 2, same query key, completely separate cache instance,
12// completely separate network request
13
14// AFTER — one leader tab performs the fetch (Section 4.4), every
15// other tab hydrates from IDB on the SAME page load with zero
16// network calls, and a write in any tab mirrors to every other tab
17// via BroadcastChannel (Section 4.11, step 8) with no extra code:
18flux.bootstrap({
19 endpoint: '/api/bootstrap/public',
20 map: { services: servicesStore },
21})
22// this one call is safe to fire from every tab — only the elected
23// leader actually hits the network, followers hydrate from disk
7

Cache invalidation, mapped directly

Same intent, same escape hatch, different name.
typescript
1// invalidateQueries has a direct equivalent, worth mapping
2// explicitly since the naming differs slightly and the underlying
3// mechanism (force a refetch, bypass freshness checks) is the same
4// idea either way.
5
6// BEFORE
7queryClient.invalidateQueries(['services'])
8
9// AFTER
10await flux.invalidate('services')
11// clears this channel's IDB entry and marks it for refetch on next
12// access — same intent, same "I know something changed, don't trust
13// the cache" escape hatch
8

What doesn't change

Request-level concerns that were never this library's job, or Flux's job either — no reason to touch these during migration.
typescript
1// What doesn't change, and doesn't need to: request-level concerns
2// that were never Flux's job or the hook library's job either.
3
4// - Request deduplication for concurrent identical calls WITHIN a
5// single render pass — this is a React rendering concern, handled
6// the same way regardless of which caching layer sits underneath.
7
8// - Pagination cursor management, infinite-scroll accumulation logic
9// — Flux's PAGINATED ingestion type (Section 4.1) is deferred to
10// Phase 14 (Section 15); for now, this stays exactly as it was.
11
12// - Non-cacheable, single-use requests — a one-time file upload
13// response, a signed URL that's read once and discarded. Nothing
14// about a caching layer helps something that's read exactly once,
15// regardless of which library is doing the caching.
16
17// - Client-side request/response transformation, retries for reasons
18// OTHER than "was offline" (e.g. exponential backoff on a flaky
19// third-party API) — these are orthogonal concerns worth keeping
20// wherever they currently live.
9

When this migration isn't worth it

Worth checking per-screen before migrating anything wholesale.
typescript
1// Signals this migration isn't worth it for a given screen, at
2// least not yet:
3
4// - The data is genuinely ephemeral and per-request — a live search-
5// as-you-type autocomplete result. staleTime: 0 and no caching at
6// all is often already the correct choice here; an IDB round trip
7// adds latency a hook library's in-memory Map doesn't have.
8
9// - The screen has no offline story worth building regardless of
10// caching layer — an admin tool nobody uses on a train.
11
12// - You're deep in a codebase with hundreds of existing useQuery
13// call sites and no near-term reload/offline/multi-tab pain. This
14// is a real migration with real surface area — see the "when to
15// migrate incrementally" note below before doing it all at once.
10

Migrating incrementally

Both models can coexist. Migrate the screens that actually feel the reload/offline/multi-tab pain first and leave the rest where it is.
typescript
1// Both models can coexist in the same app during migration — Flux
2// doesn't require an all-or-nothing swap. A common path: migrate the
3// screens that actually suffer from the reload/offline/multi-tab
4// gaps first (dashboards, anything with a mutation a user would be
5// upset to lose), leave low-stakes or genuinely ephemeral queries on
6// the hook library, and let the two approaches sit side by side for
7// as long as makes sense.
8
9// Nothing about Flux's registration model requires the whole app to
10// go through flux.bootstrap() — a store can be registered and
11// populated by its own bootstrap call independent of every other
12// registered store (Section 4.13, per-scope bootstrap calls apply
13// the same principle to migration order, not just to global vs.
14// user data).
No all-or-nothing requirement

A store's registration and bootstrap call are independent of every other registered store — there's no global switch that forces the whole app onto Flux at once.

For what a browser-native cache does and doesn't replace in general, see core-concepts/what-flux-is-not. For the offline queue mechanics referenced in Step 5, see core-concepts/offline-queue. For cross-tab leader election referenced in Step 6, see core-concepts/browser-physics.