Skip to content

12 - Cross-cutting patterns

The patterns that span every screen but didn’t fit cleanly in any single chapter: concurrent-edit conflict resolution, recovery (offline / crash / stale data), cross-tab synchronization, the bulk-action confirmation matrix, and frontend-side idempotency.

05-patterns.md and 06-flows.md own everything else (feedback, optimistic UI, undo, save models, confirm-on-discard, bulk-action toolbar). This file fills only the remaining gaps.

Scope rule. If a topic is already covered in 05-patterns.md or 06-flows.md, it is not repeated here. Each section below points back to the existing rule it builds on.

Prereqs: 05-patterns.md, 06-flows.md.


Concurrent edit collisions

Two users edit the same record. User A saves first; user B saves second. What happens to user B?

Today nx-agent has no enforced answer. This is the spec for when collisions start mattering — multi-user agent editing, shared workflow canvas, team-wide settings.

The contract

Every editable record carries an updatedAt timestamp (ISO-8601, server-issued). Every mutation that updates such a record sends the updatedAt it loaded with, not just the new fields:

PUT /v1/agents/:id
{ "name": "New name", "ifMatchUpdatedAt": "2026-04-28T03:21:14.012Z" }

The server compares ifMatchUpdatedAt to the current row’s updated_at. If they match → commit and return the new updated_at. If they don’t → respond 409 Conflict with the current server state.

Frontend behavior on 409:

Edit shapeResolution
Single-property inline editRoll back the optimistic value. Show inline error: “Updated by someone else. [Reload]” — the link refetches and re-renders. No retry-with-overwrite.
Multi-field form (Settings, Agent config)Surface a <Dialog>: “Someone else changed this while you were editing. [Discard my changes] / [Keep editing — your save will overwrite].” Default to Discard.
Workflow canvas (Model C auto-save)Pause auto-save. Banner at top: “Out of sync — reload to continue.” Reloading drops the user’s unsaved changes; this is a stated tradeoff.

The “overwrite” path exists for cases where the user knows they’re right (typo fix, rollback, deliberate stomp). Default to Discard so the careful path is one click and the destructive path is two.

Why not last-write-wins

Silent last-write-wins corrupts data without telling the user. We treat conflicts as a real error class — they always surface. The 409 round-trip is one extra request only when conflicts actually happen.

Why not real-time CRDTs

Real-time collaborative editing (Figma-style) is a separate product investment with its own primitive set. Until that lands, optimistic UI + ifMatchUpdatedAt + 409 resolution is the contract.


Recovery — offline, crash, stale data

What happens when the network drops, the tab crashes, or the data is silently older than what the server has.

Offline detection

Use the browser’s online / offline events. When offline fires:

[Banner at the top of <PageContent>, role="status", non-dismissible]
"Offline — changes will sync when you reconnect."

While offline:

  • Reads — render the last cached data. TanStack Query with staleTime ≥ 30s already does this; do not invalidate on offline.
  • Mutations — every <ActionButton> shows a queued state (“Will retry when online”). The mutation is held in memory and re-fired on online. Toasts are suppressed while offline.
  • Long-running jobs (auto-save, SSE streams) — pause and resume on reconnect.

Banner clears on the next successful network response, not the online event (the event fires before connectivity is real).

Tab crash / refresh recovery

The default rule, reinforced. Refreshing or crashing during edit loses unsaved input. This is acceptable for occasional tasks (settings edit, wizard step) and is the tradeoff 06-flows.md → Save state on Next makes for wizards.

Exception — Model C auto-save surfaces. Workflow canvas, prompt editor, rich-text content. Auto-save with debounce 1–2 s already covers the recovery story: refresh re-loads from server, the last debounced state is there. No additional work.

Exception — long-form authored content. If a single text field accumulates ≥30 s of typing without a save (e.g., a long agent system prompt under explicit-Save Model A), persist a draft to localStorage keyed by (route, fieldId) on every change, debounced 500 ms. On reload, if the server value matches the user’s last-loaded value AND a draft exists, render a banner: “Restore unsaved draft? [Restore] [Discard].” Discard clears the localStorage key.

Hard rule. Do not auto-restore drafts silently. The user must opt in — silent restore creates “stale draft” confusion (the user comes back days later and doesn’t remember why their text looks weird).

Stale data refresh

TanStack Query handles most of this. The decisions:

TriggerBehavior
Window refocusrefetchOnWindowFocus: true — default. The user expects fresh data when returning to the tab.
Filter / sort changeNew query key → fresh fetch. Show skeleton overlay over the table; preserve scroll.
Navigation back to a listRead from cache, fire a background refetch. The user sees the prior data instantly; updates land within 1 frame.
Mutation successInvalidate the affected query keys. Don’t manually patch caches unless you’ve verified the patch path against the server response — patching diverges from server state quickly.
Server-pushed event (SSE / websocket)Invalidate the matching query key. Don’t manually merge — let the next render fetch.
Stale-but-no-triggerIf the data is shown for > staleTime and no trigger fires, the cache is still served. Acceptable. Stale-while-revalidate is the right tradeoff against fetch storms.

Forbidden: polling at < 30 s intervals as a substitute for invalidation. If the data needs to be fresher than 30 s, the screen is real-time → use Template C (Monitoring) or SSE.


Cross-tab synchronization

The user has two tabs open on the same app. Edits in tab A should not leave tab B with stale data.

The contract

Use BroadcastChannel('nx-agent') to publish mutation completions across tabs. Every successful mutation publishes { type: 'invalidate', queryKey: […] }. Listening tabs invalidate the matching keys.

// In api/agents.ts
const channel = new BroadcastChannel('nx-agent');
export function useUpdateAgent(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: …,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['agents', id] });
channel.postMessage({ type: 'invalidate', queryKey: ['agents', id] });
},
});
}
// In a top-level provider
useEffect(() => {
const handler = (ev: MessageEvent) => {
if (ev.data?.type === 'invalidate') {
queryClient.invalidateQueries({ queryKey: ev.data.queryKey });
}
};
channel.addEventListener('message', handler);
return () => channel.removeEventListener('message', handler);
}, []);

Auth / session changes

Sign-out in one tab signs out all tabs. Publish { type: 'logout' } on the broadcast channel; listening tabs call logout() from @na/auth and reload to /login. This avoids the bug where tab B continues to act on a stale token after the user signed out in tab A.

Theme / sidebar preference

Theme and sidebar-collapsed state already live in localStorage. The browser fires storage events on cross-tab localStorage writes — wire the theme provider to listen and apply. No BroadcastChannel needed for these.

What does NOT cross tabs

  • Form input state. Tab A’s unsaved form is not synced to tab B. The user is editing two different things.
  • Selection state. Tab A’s bulk-selection in <DataTable> is not synced to tab B.
  • Optimistic UI in flight. Tab A’s pending mutation does not show as pending in tab B. Tab B sees the mutation only after it commits.

Browsers without BroadcastChannel

Old Safari / iOS < 15.4. Fallback: localStorage write of a synthetic key (__nxa_invalidate__) with a value containing the query key. The storage event is the cross-tab signal. Treat both paths in one helper; do not branch on every call site.


Bulk-action confirmation matrix

05-patterns.md → Bulk action toolbar defines the toolbar shape (placement, label, max 3 visible actions, sticky on scroll). 06-flows.md → Bulk actions defines the flow (selection → confirm → progress → result).

What’s missing is the per-action decision: which bulk actions confirm, which run inline, and how the confirmation escalates with item count.

The matrix

Action class< 5 items5–50 items50–1000 items> 1000 items
Reversible (tag, label, status change, assign owner)No confirm — inline. Toast “Tagged 3 contacts.”No confirm — inline. Toast with count.<ConfirmPopover> with “Tag {N} contacts?” — undo via toast.Background job. Toast “Tagging {N} contacts in the background…”
Reversible-with-undo (delete, archive)<ConfirmPopover> with undo toast (5–10 s).<ConfirmDialog> with undo toast (10 s).<ConfirmDialog> + type-the-count to confirm. Undo 30 s.Forbidden as a single action. Force the user to filter to ≤1000.
Irreversible (send email, charge, publish)<ConfirmDialog> with type-the-count. No undo.<ConfirmDialog> with type-the-count. No undo.<ConfirmDialog> with type-the-entity-name AND type-the-count.Background job + email confirmation receipt. No in-app undo.
Side-effect-only (export, run report)No confirm — inline.No confirm — inline.Background job. Toast on completion.Background job. Email when ready.

Read the matrix as: the row picks the confirmation primitive, the column picks the friction added to that primitive.

Why the count threshold escalates

ThresholdReason
5Below 5 items, the user can re-do the action manually. Confirm is friction without value.
50At 50, manual undo is no longer feasible. The system must offer undo (toast) or refuse the action.
1000At 1000, the operation is no longer interactive. The user is starting a job, not clicking a button. Treat it as such — <ActionButton> with pending → toast “Started.”
> 1000Catastrophic-by-volume. Force the user to refine the selection. Doing 5000 deletions in one click is almost always a mistake.

06-flows.md → Bulk actions already states “max 1000 items per action.” This matrix is the per-action escalation policy below that ceiling.

”Type the count” friction

For irreversible bulk actions ≥ 5 items and reversible-with-undo ≥ 50 items, the <ConfirmDialog> requires the user to type the count:

Delete 47 contacts?
This permanently deletes 47 contacts and their conversation history.
Type "47" to confirm: [____]
[Cancel] [Delete 47 contacts]

The Delete button is disabled until the input matches the count. This prevents one-click catastrophes; the user pauses long enough to read the title.

Mixing actions in the same toolbar

If the bulk toolbar exposes actions across classes (e.g., Tag + Delete), follow 05-patterns.md → Bulk action toolbar: destructive actions go inside the dropdown when they coexist with non-destructive — never side-by-side as visible row buttons. The dropdown adds one click of friction, which is the right escalation when the user might overshoot.


Frontend idempotency

Every mutation may be retried — by the user (rage-click), by the network layer (timeout-then-success), by BroadcastChannel echo, by a re-mounted component. The frontend’s job is to make duplicates safe.

Read mutations are always idempotent

Trivially. Skip ahead.

Create mutations — idempotency keys

Generate a UUID v4 client-side at the moment the user commits (button click, form submit). Send it as Idempotency-Key header. The same key on a retry returns the original response, not a second create:

const idempotencyKey = useRef(crypto.randomUUID());
const create = useMutation({
mutationFn: (data) =>
apiFetch('/v1/agents', {
method: 'POST',
headers: { 'Idempotency-Key': idempotencyKey.current, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
});

The key is generated at commit time, not on mount — mounting a form should not pre-allocate a key. Re-mounting after a crash generates a new key, which is correct (the user is re-trying).

Update mutations

The ifMatchUpdatedAt from the conflict-resolution section already gives idempotency: replaying the same update twice is a 409 on the second call (because the first changed updated_at). No separate idempotency key needed.

Delete mutations

Deleting an already-deleted record returns 404 or 204. Treat both as success — the goal state is reached. Do not surface a 404 as an error to the user.

Multi-step submits

If a wizard’s final commit is N sequential calls, each call has its own idempotency key. A retry of the whole wizard re-uses the original keys (so half-committed state isn’t doubled). The keys live in the wizard’s reducer state (06-flows.md → Multi-API submit) — not in useRef, because reducer state is what survives re-renders.

Backend support assumed

This pattern requires backend support for Idempotency-Key and If-Match. If the backend doesn’t yet support a given header, document the gap in the API call’s hook (// TODO: backend pending Idempotency-Key on POST /v1/agents) — do not silently degrade. The failure mode of “key sent, key ignored, duplicates created” is exactly what idempotency keys are meant to prevent.


Composition — how these patterns fit together

A canonical scenario that exercises every pattern in this file: two users editing the same agent, one of them with two browser tabs open, on a flaky network.

  1. User A, tab 1: opens /agents/abc/settings, edits “name” inline. The mutation includes ifMatchUpdatedAt and a fresh Idempotency-Key.
  2. Network drops mid-mutation. <ActionButton> shows pending. Offline banner appears. The mutation queues.
  3. Network returns. The queued mutation re-fires with the same Idempotency-Key — backend recognizes the key and returns the original (still-applied-once) response. Optimistic value commits.
  4. onSuccess fires. TanStack Query invalidates ['agents', 'abc']. BroadcastChannel publishes the invalidation.
  5. User A, tab 2: receives the broadcast, invalidates its ['agents', 'abc'], refetches in the background. Tab 2’s view updates without user action.
  6. User B: meanwhile, user B (different session) had /agents/abc/settings open and edits “description”. Their mutation sends a now-stale ifMatchUpdatedAt.
  7. Backend returns 409 with the current state. User B’s <Dialog> opens: “Someone else changed this while you were editing.” User B clicks Discard.
  8. The system is consistent across two users, two tabs, one offline event, one conflict — and surfaced every state to the right user.

If any pattern in this file is missing, one of those steps becomes a silent corruption.


Next: jump back to 03-layout.md for archetype reminders, 11-templates-catalog.md for the template index, or 10-review-checklist.md for the review-time scan list.