[Deep Dive #24] Form Cadence Courts - discussion
We’re rebuilding a couple “checkout-ish” flows and the thing we keep failing at is making the form feel instant while routes are moving underneath it.
Do you actually run uncontrolled-first and only introduce controlled inputs as a debug surface? How do you keep keystroke validation and async checks narratable when there’s no cancellation and “latest wins”? Have you shipped dual-truth inputs in anger, and what did you render as evidence so support could reason about it?
Comments (10)
Back to latestUncontrolled-first is the only posture where typing stays good during navigation. The trick is making the *judgment* visible.
The “controlled primarily for debugging” idea clicked for us once we stopped treating control as a default.
We render the debug value + last judgment so you can tell what the route *thinks* is true.
Related: Forms and Validation Best Practices and Routing and Navigation.
Dual-truth can work if you treat it as a posture key and render it:
tsx
export function EmailCourt() {
const court = useRouteState({ mode: 'dual' as 'uncontrolled' | 'controlled' | 'dual' });
const debug = useLiveState('email-debug', { value: '', judged: 'unknown' as string });
return (
<>
<Field
defaultValue=""
value={court.read('mode') === 'controlled' ? debug.read('value') : undefined}
onFieldChange={(v) => debug.write('value', v)}
/>
<div data-court={court.read('mode')} data-judged={debug.read('judged')} />
</>
);
}Counterpoint: async validation without cancellation feels like inviting ghosts.
If the network is slow, “latest wins” can still show intermediate messages that confuse users unless you render the decision trail very carefully.
Yeah — I think the post’s claim only holds if you treat each check as evidence, not truth.
We started rendering lastCheckedAt + the input value that was checked, and the confusion dropped a lot.
That makes sense. If you show what was checked, the user can reconcile “why did it say that”.
We leaned into blur-commit for “route moves” and it was surprisingly calming.
Keystrokes can stay optimistic; blur is where you commit to the route and run the heavier checks.
The thing that saved us was treating the court as a route posture, not a component detail.
If the route decides “keystroke judgment on/off”, you can reproduce behavior by looking at one marker on the page.
We modeled “latest wins” as a monotonically increasing check id and rendered it.
tsx
const state = useLiveState('email-court', { checkId: 0, lastValue: '', ok: true });
function runAsyncCheck(value: string) {
const id = state.read('checkId') + 1;
state.write('checkId', id);
state.write('lastValue', value);
// fire request; when it returns, only apply if id matches current
}
return <div data-check={state.read('checkId')} data-value={state.read('lastValue')} />;We shipped a dual-truth input once and the bug wasn’t “state mismatch”, it was “support can’t tell which truth won”.
Once we rendered the mode + judgment markers, it stopped being scary.
If you’re validating every keystroke, you need a clear rule for which errors are “soft” vs “blocking”.
Otherwise it’s just a blinking wall of red while someone types.
Treat the whole thing as one surface (mega component posture) and it becomes reviewable.
When it’s split across five hooks, the cadence court becomes folklore.