Model Selected and Loaded Identity Separately for Async Navigation
Jul 02, 2026
Week-specific columns looked empty while a new period loaded. The instinct was to add a spinner. The real problem was state correctness: the interface could filter old data against a newly selected week, accept obsolete fetch results, and leave stale controls actionable.
The problem
The surface symptom is visual—empty or incomplete columns during navigation. The deeper failure is a mismatch between three things that should stay aligned:
- the period the user selected in the navigator;
- the period represented by the currently committed task data;
- the period used by mutation handlers.
Without an explicit loaded-period identity, a generic loading flag cannot tell you whether the data on screen belongs to the week the user just chose. Old data can look like new data. New selections can render against stale datasets. Mutation buttons can stay enabled while the view is still catching up.
What actually happened
What looked like a small UX improvement crossed several interacting concerns: asynchronous request ordering, stale success and stale failure handling, single-flight fetch coordination, displayed-data identity, mutation permission, nested dialog state, recursive card rendering, and shared dialog components used elsewhere in the product.
The most important design decision was separating selected identity from loaded identity:
- selected identity — where the user wants to go;
- loaded identity — which period produced the committed data;
- can mutate — whether those identities match;
- in transition — whether the user has selected a different period that has not yet committed.
That gave one authoritative rule for both rendering and mutation safety instead of relying on a generic isLoading flag. Same-period realtime or post-mutation refreshes did not unnecessarily freeze the board when loaded and selected identities still matched.
Keeping old data visible was safe only when combined with masking and mutation guards. The final answer was not unrestricted data preservation. It was:
- retain the last coherent loaded period;
- render it against its own identity;
- place a clear transition mask over it;
- keep navigation available;
- block mutation controls;
- guard handlers as well as buttons;
- replace the data only when the selected period's result commits.
Obsolete failures mattered as much as obsolete successes. A late failure from an abandoned period could incorrectly publish an error for the current one. Success and failure needed symmetric identity checks at settlement.
Error swallowing was part of the defect. A fetch helper that returned an empty array on database failures made a failed request indistinguishable from a genuinely empty period. Changing the contract to throw, preserving last-known data, and showing an inline retry state was necessary to make the UI truthful.
Dialog invalidation was broader than the first implementation proved. Board-owned dialog state can be reset centrally; internally owned dialog state needs an explicit permission-change close mechanism. Recursive cards and shared dialogs are common mutation-guard bypass points.
The lesson
For asynchronous navigation, model three contracts before choosing a loading treatment:
- Selected identity — what the navigator represents right now.
- Loaded identity — what produced the committed data on screen.
- Mutation authority — whether those identities match enough to allow edits.
Decide separately what remains rendered, what remains interactive, what happens to obsolete successes, and what happens to obsolete failures. A transition overlay can block mutations while leaving navigation enabled. Rapid navigation should converge on the final selection rather than requiring throttling.
Treat "error versus empty result" as an acceptance criterion. An empty dataset and a failed fetch are not the same user experience.
The broader principle
Async navigation UX is rarely only a spinner problem. The correct fix requires explicit data identity, stale-settlement rejection, truthful error handling, mutation safety, and dialog invalidation on identity change.
One authoritative mutation gate must propagate through parent components, recursive children, dialogs, keyboard handlers, and shared components. Inventory every open dialog and pending confirmation that must invalidate on navigation—not only the ones owned by the parent view.
For verification, map acceptance criteria to executable evidence and include negative cases that would fail against the old behaviour: stale successes dropped, stale failures dropped, rapid A→B→C navigation converging, same-period refreshes still interactive, and mutation controls blocked during transition. Visual acceptance still needs actual browser review for masks, flicker, focus, and perceived responsiveness.
How to apply it
- Separate selected and loaded identity. Do not assume the navigator state equals the committed dataset.
- Define the transition contract. Decide what stays visible, what gets masked, and what stays interactive.
- Guard mutations at the authority layer. Disable buttons and block handlers when identities diverge.
- Reject obsolete outcomes symmetrically. Apply identity checks to both success and failure settlement.
- Make errors truthful. Do not represent failed fetches as valid empty datasets.
- Invalidate dialogs on identity change. Audit parent-owned and internally owned dialog state.
- Propagate permission through shared and recursive consumers. Nested cards and shared dialogs are bypass points.
- Test rapid navigation with deferred requests. Prove convergence on the final selection.
- Require manual preview for visual transitions. State-level tests cannot prove paint timing or perceived responsiveness.
The spinner is optional. Identity discipline is not.