Offline Patterns

Conflict resolution UI

Every handshake-replay store can produce a conflict on reconnect. Rather than building a resolution UI per store, Flux funnels all of them through one callback and one array — this page covers wiring that up, what each button actually calls, and the one place a shallow-merge default isn't enough.

Prerequisites
  • At least one store registered with replayStrategy: 'handshake' — see core-concepts/conflict-resolution
  • @tsworldtech/flux-react or @tsworldtech/flux-next installed, if using the built-in FluxConflictPanel
  • Familiarity with api/get-active-conflicts and api/resolve-unified-conflict
1

Why one panel instead of one per store

A conflict can come from any handshake-replay store, at any reconnect. Flux funnels every one of them through a single callback, driving a single UI, rather than requiring separate resolution logic per store.
typescript
1// A handshake-replay store (Section 4.6) can produce a conflict on
2// EVERY reconnect — any store, at any time. Building a separate
3// resolution UI per store doesn't scale past two or three of them.
4//
5// So Flux funnels every conflict, from every store, through ONE
6// callback (Section 4.1):
7
8interface FluxConfig {
9 onConflictUnified?: (conflicts: UnifiedConflictFrame[]) => void
10}
11
12// Always called with the FULL current list, not just what just
13// changed — grows when a new conflict lands, shrinks when
14// resolveUnifiedConflict() runs, and fires with an empty array when
15// the last one clears. One callback, one array, one place to drive
16// a single conflict panel for the entire app — which is what
17// FluxConflictPanel is built to consume directly.
2

The full wiring

useFlux()'s activeConflicts already mirrors the engine's live register — no manual subscription or state management needed.
typescript
1// The full wiring, top to bottom. useFlux's activeConflicts mirrors
2// engine.getActiveConflicts(), kept in sync via the onConflictUnified
3// subscription internally — no manual state needed on your side.
4
5import { createFlux } from '@tsworldtech/flux'
6import { useFlux } from '@tsworldtech/flux-react'
7import { FluxConflictPanel } from '@tsworldtech/flux-react'
8
9const flux = createFlux({
10 adapter: createSupabaseAdapter(supabaseClient),
11 // onConflictUnified is optional to set here — useFlux's
12 // activeConflicts already reflects the register's live state
13 // whether or not you also hook this callback yourself
14})
15
16function App() {
17 const { activeConflicts } = useFlux()
18
19 return (
20 <>
21 {/* rest of your app */}
22
23 <FluxConflictPanel
24 conflicts={activeConflicts}
25 onResolve={flux.resolveUnifiedConflict}
26 onAbandon={flux.abandonQueueEntry}
27 />
28 </>
29 )
30}
31
32// That's the entire integration. The panel renders nothing
33// (returns null) when activeConflicts is empty — no conditional
34// mounting logic needed on your side either.
3

What actually produces a conflict

Only replayStrategy: 'handshake' stores can ever reach this panel — fire_and_forget stores have no server-state comparison to conflict against in the first place.
typescript
1// Conflicts don't come from nowhere — only stores registered with
2// replayStrategy: 'handshake' can ever produce one (Section 4.6).
3// fire_and_forget stores never route through this panel at all,
4// because there's no server-state comparison for them to conflict
5// against in the first place.
6
7flux.register({
8 store: dashboardStore,
9 channel: 'dashboard',
10 idbKey: 'dashboard',
11 ttl: 'short',
12 scope: 'user',
13 queueConfig: {
14 storeName: 'dashboard-edits',
15 replayEndpoint: '/api/dashboard/batch-update',
16 replayStrategy: 'handshake',
17 conflictStrategy: 'reject', // fallback if onConflictUnified
18 // isn't wired for this store — see
19 // "reject" vs "drop" note below
20 revalidateFn: async (entries, manifest) => {
21 const res = await fetch('/api/dashboard/revalidate', {
22 method: 'POST',
23 body: JSON.stringify({ manifest }),
24 })
25 return res.json() // { conflictedIds, serverStates }
26 },
27 },
28})
29
30// A conflict routes to the universal register (and therefore to
31// this panel) specifically when the engine's onConflictUnified is
32// configured AND the per-channel onConflict callback either isn't
33// set or returns 'drop'. If neither is configured, conflictStrategy
34// applies automatically and the panel never sees it at all.
4

What each button actually calls

The panel's buttons are a thin, direct wrapper — nothing translated or reinterpreted between the UI and resolveUnifiedConflict().
typescript
1// The panel's three primary buttons map directly onto
2// resolveUnifiedConflict()'s decision argument — nothing translated
3// or reinterpreted in between:
4
5// "Keep mine" -> onResolve(recordId, 'proceed')
6// sends the local payload, overwriting
7// whatever changed on the server
8//
9// "Keep server" -> onResolve(recordId, 'drop')
10// discards the local queued edit entirely,
11// server version wins
12//
13// "Save merge" -> onResolve(recordId, 'apply_merged', payload)
14// only shown in Merge view — sends a
15// field-by-field combined payload
16
17// All three remove the conflict from the register immediately and
18// re-fire onConflictUnified with the reduced list (Section 4.14) —
19// the panel doesn't need to poll or refetch, activeConflicts updates
20// on its own.
5

The Merge view's shallow-spread limit

Worth reading closely if any conflicting records have nested objects — the default merge behavior isn't what it might look like at first.
typescript
1// The Merge view builds its payload with a shallow spread:
2//
3// { ...server, ...local, ...mergeEdits }
4//
5// This is fine for flat records — every field the user didn't
6// explicitly pick in the Merge UI falls back through server, then
7// local, then whatever they clicked. It is NOT fine for nested
8// objects: picking "mine" for a nested field replaces the WHOLE
9// nested object, not just the field that actually changed inside it.
10//
11// IMPORTANT — this is NOT what QueueConfig.mergeHandler is for.
12// mergeHandler (Section 4.1) only runs for dedupeStrategy: 'replace'
13// — merging two QUEUED entries together before either one is sent.
14// It has no involvement in resolving a handshake conflict against
15// server state at all.
16//
17// The actual per-conflict merge path is the queueConfig.onConflict
18// callback returning 'apply_merged' with your own mergedPayload
19// (Section 4.6) — if your records have nested objects, build your
20// OWN merge logic there rather than relying on this panel's default
21// shallow-spread Merge view for those fields:
22
23queueConfig: {
24 // ...
25 onConflict: (entry, serverState) => {
26 if (hasNestedFields(entry.payload)) {
27 return {
28 action: 'apply_merged',
29 mergedPayload: deepMerge(serverState, entry.payload),
30 }
31 }
32 return { action: 'drop' } // fall through to the panel for flat records
33 },
34}
mergeHandler is a different mechanism entirely

QueueConfig.mergeHandler only applies to dedupeStrategy: 'replace' — merging two queued entries before either is sent. It has no role in resolving a conflict against server state. For nested-object conflicts, build custom merge logic inside queueConfig.onConflict instead.

6

Discard vs. resolve — two different calls

The panel's "Discard my edit" button and a "Keep server" resolution look similar in the UI but carry different intent underneath.
typescript
1// "Discard my edit" calls flux.abandonQueueEntry(recordId) — a
2// distinct engine method from the 'drop' decision above, worth
3// telling apart:
4//
5// resolveUnifiedConflict(id, 'drop')
6// -> this is a RESOLUTION. It says "I've looked at the
7// conflict, the server wins." Removes the register entry
8// as part of resolving an active conflict.
9//
10// abandonQueueEntry(id)
11// -> removes a queued entry OUTRIGHT, independent of the
12// conflict flow — useful for "actually, forget this edit
13// entirely" regardless of whether it ever reaches a
14// server-conflict check at all.
15//
16// In the panel's UI both buttons end up clearing the card, but they
17// carry different intent. If you're building your own resolution UI
18// instead of using FluxConflictPanel, keep that distinction rather
19// than treating them as interchangeable "remove this" calls.
7

Quick path — import it as-is

Everything needed if the default dark styling already fits your app.
typescript
1// Everything the quick-import path needs:
2
3import {
4 FluxConflictPanel,
5} from '@tsworldtech/flux-react'
6// identical export from '@tsworldtech/flux-next' — same file, both
7// packages ship it
8
9import type {
10 FluxConflictPanelProps,
11} from '@tsworldtech/flux-react'
12
13import type {
14 UnifiedConflictFrame,
15} from '@tsworldtech/flux'
16// storeName, recordId, localEntry (full QueueEntry — access
17// .payload for the user's offline edit), serverState (Section 4.1)
18
19// ResolveOptions (the panel's onResolve fourth argument, currently
20// just { reevaluate?: boolean }) isn't in the core type reference
21// alongside resolveUnifiedConflict's documented signature — confirm
22// the exact shape against @tsworldtech/flux's shipped types before
23// relying on it in your own resolution UI.
8

Why this one is worth copying instead

A conflict-resolution surface is exactly the kind of UI teams tend to want full control over.
typescript
1// FluxConflictPanel is more deeply styled than most Flux components
2// — colors are inline throughout the styles object rather than
3// collected in one place (#7dd3a8 for "your version," #f0a070 for
4// "server version," #0f0f0f panel background), plus a wiggle
5// keyframe injected directly into <head> on mount.
6//
7// None of this reads your app's theme. It's a genuinely nice-looking
8// dark panel — but it's Flux's dark panel, not necessarily yours.
9// A conflict-resolution UI is also the kind of surface a team
10// usually wants full control over: which fields get highlighted,
11// whether "Keep server" needs a confirmation step, whether the
12// wiggle animation is too much for a finance dashboard.
13//
14// Same principle as the upload pattern: import the wiring
15// (resolveUnifiedConflict / abandonQueueEntry / activeConflicts),
16// copy the panel.
9

Full source — copy this into your project

The wiring and resolution logic carry over unchanged. The one edit worth making immediately is consolidating the scattered inline colors into a single object, shown here.
typescript
1// packages/flux-react/src/components/FluxConflictPanel.tsx
2//
3// Copy into your own project and restyle freely. Below, the scattered
4// inline colors from the shipped version are consolidated into one
5// COLORS object at the top — this isn't how the package ships it,
6// but it's the first edit worth making once it's yours, so future
7// restyling means changing one object instead of hunting through the
8// styles map for every #7dd3a8 and #f0a070.
9
10import { useState, useMemo, useCallback, useEffect } from 'react'
11import type { UnifiedConflictFrame } from '@tsworldtech/flux'
12
13export interface ResolveOptions {
14 reevaluate?: boolean
15}
16
17export interface FluxConflictPanelProps {
18 conflicts: UnifiedConflictFrame[]
19 onResolve: (
20 recordId: string,
21 decision: 'drop' | 'proceed' | 'apply_merged',
22 mergedPayload?: any,
23 options?: ResolveOptions
24 ) => Promise<void>
25 onAbandon: (recordId: string) => Promise<void>
26 reevaluateOnResolve?: boolean
27 renderDiff?: (field: string, local: any, server: any) => React.ReactNode
28 className?: string
29}
30
31// ── EDIT THIS — one place for every color in the panel ──────────────
32const COLORS = {
33 bg: 'var(--flux-conflict-bg, #0f0f0f)',
34 surface: 'var(--flux-conflict-surface, #141414)',
35 border: 'var(--flux-conflict-border, #2a2a2a)',
36 text: 'var(--flux-conflict-text, #e8e8e8)',
37 muted: 'var(--flux-conflict-muted, #666)',
38 accentWarn: 'var(--flux-conflict-warn, #f0a070)', // "server version" / icon
39 accentDanger:'var(--flux-conflict-danger, #e24b4a)', // count badge
40 local: 'var(--flux-conflict-local, #7dd3a8)', // "your version"
41 merge: 'var(--flux-conflict-merge, #7db3f0)',
42}
43
44type ConflictView = 'diff' | 'merge'
45interface ConflictCardState {
46 mergeEdits: Record<string, any>
47 view: ConflictView
48 collapsed: boolean
49}
50
51function getFields(local: any, server: any): string[] {
52 return Array.from(new Set([...Object.keys(local ?? {}), ...Object.keys(server ?? {})]))
53}
54
55function isSameValue(a: any, b: any): boolean {
56 if (a === b) return true
57 if (a === undefined || b === undefined) return false
58 try { return JSON.stringify(a) === JSON.stringify(b) } catch { return false }
59}
60
61// ...DefaultDiff, ConflictCard, ensureWiggleStyle, and the main
62// FluxConflictPanel component all carry over structurally unchanged
63// from the shipped version — only the styles map's color literals
64// get replaced with COLORS.* references. The full body is long
65// enough that it's not repeated inline here; pull it from the
66// package source (node_modules/@tsworldtech/flux-react/src/components/
67// FluxConflictPanel.tsx) and do a find-replace of the hex values
68// against the COLORS object above as the mechanical first step.
69
70export function FluxConflictPanel(props: FluxConflictPanelProps) {
71 // ...unchanged logic — see package source
72 return null
73}
The panel's logic is the reusable part

Everything driven by onResolve/onAbandon stays identical once copied — only the visual layer is meant to change.

10

A licensing note

What's actually tier-gated here, and what isn't.
typescript
1// Like the upload pattern, this component itself isn't
2// license-gated — the FEATURE underneath it is. handshake replay,
3// conflictStrategy, and the universal conflict register are all
4// Pro/Enterprise-tier (Section 4.12's tier matrix). Free and Starter
5// tiers can still register a store, they just can't set
6// replayStrategy: 'handshake' meaningfully — fire_and_forget stores
7// never produce a conflict for this panel to show.

For the two-pass handshake mechanics behind every conflict shown here, see core-concepts/conflict-resolution. For the register's internal lifecycle, see api/get-active-conflicts and api/resolve-unified-conflict. For the offline upload pattern, which uses a similar copy-vs-import tradeoff, see offline/upload-pattern.