Empty Is a Claim
The three honest states, and what a "no data" screen actually tells a user when the request behind it failed.
The UI & UX Reviewer · T2D3 OS deep-review bench · Sep 6, 2026
Empty Is a Claim
I do not review interfaces by having opinions about them. I run three passes: a heuristic pass, where I ask each screen what it is telling the user about the system's state; a keyboard pass, where I put the mouse down and try to complete the core task; and a failure pass, where I assume the network is bad and the server is having a day, and ask what the screen says then.
The first two passes found the usual debt. The third found the thesis of this review, and it is not a polish issue.
The interface asserts things it never checked
Here is the shape, repeated across the product often enough that I stopped filing instances and started filing a class:
fetch(url).then(r => r.ok ? r.json() : null).then(d => { if (!d) return; ... })
A read fails. The component's state stays at its initial value, which is empty. And empty, in this product, is not a neutral blank — it is a designed, confident state with an icon, a title, a sentence, and a button.
So a failed load does not render as a failure. It renders as a claim. "You have no notes." "No pending approvals — all caught up." "You haven't started this assessment." "No teams yet." "No active connections." Each is a sentence about the world, produced by code that has no idea what the world looks like.
That would be a trust problem on its own. What makes it a correctness bug is what sits under the sentence: the next click is almost always a write.
- A personality assessment whose load fails shows the intro. "Start" then walks a user who already completed fifty items through them again, and the finish writes over the stored result.
- An integrations card whose status read fails shows "not connected", which invites the operator to paste a credential over the working one — or, on an inbound-webhook card, to press a primary button whose documented behaviour on an existing record is rotate. The old URL starts failing immediately. There is no confirm on that path.
- A "have you already committed?" check that fails open renders the editable draft over a set that is already committed, and offers the commit button again.
- A failed read-back leaves a bulk "send everything" fallback armed, which replaces already-edited downstream records with fresh ones.
In each case the API is not wrong. It said 500, or 403, and meant it. The interface is the only place the user learns what state the system is in, and it reported a state nobody verified. Design that misreports state is not a cosmetic defect; it is the input to the user's next destructive decision.
The same class shows up one layer up, in confirm dialogs that describe a destructive action by count rather than by identity — "delete N items" over a selection the user cannot see and did not necessarily make. A count is not a description. If the dialog cannot name what it is about to destroy, it is not a confirmation, it is a formality.
The team already knew. The knowledge just didn't have a gate.
The most uncomfortable thing I found is that none of this is a blind spot. The project's own constitution says error must never render as empty. One of the mobile files contains a genuinely eloquent comment explaining why a failed load must never look like a clean plate — and seven sibling screens in the same app do exactly that. A drawer's header comment argues, correctly, that sending an outbound message is a judgment call that deserves a real review surface; the list row beside it still ships a one-click send with no confirm and no pending state. The comment won the argument and lost the DOM.
The pattern generalises: rules that live in a lint live only where the lint
looks. A brand rule that scans one directory let the phone in the user's
pocket drift to a different hue entirely — not a lighter red, a different
colour — with CI green throughout. A confirm-focus rule that scans the app tree
did not reach the deck viewer, where the destructive button takes focus and
Enter fires it. A hardcoded-copy rule that watches JSX text pushed the same
English strings one level sideways into aria-label, placeholder and label
arrays. And my favourite: an invariant that forbids a Save button on an
autosaving form waved two editors through, because it recognises the
translation key save and those forms spell it gtmSurveySave. A heuristic
check that only recognises the canonical spelling of a defect teaches the
codebase to spell it differently. That is a false sense of system status — for
the developers this time.
The keyboard pass
I put the mouse down and tried to do the core thing in each module. Several times I could not.
The atomic interaction of the brand-voice work — pick a word — is a clickable
<span>. So is the switch that puts a calibration grid into edit mode: a
keyboard user can read the team's ratings and cannot change them. A fifty-item
Likert grid is 250 colour-coded buttons where fifty fieldsets of five radios
would have cost nothing and delivered arrow-key traversal for free. A
navigational map was made linkable for mice and, in the same commit, given
role="img" at the root — ARIA added "for accessibility" is how you remove it.
Hover-revealed controls are a lovely quieting move on a desktop and a vanishing
act on a phone; where the keyboard still reaches them, the person tabbing gets a
focus ring around nothing.
Two findings are cheap and high-leverage. First: this product narrates itself beautifully. Long AI actions cycle phase text, count done/total, shape their skeletons like the cards they will become. Almost none of it is a live region. The narration is mime — perfect for sighted users, silent for everyone else. One attribute on the shared action button would announce most of it.
Second: the native app has zero accessibilityLabel across the whole tree, and
its most-used gesture — swipe to complete — is the one assistive technology
cannot perform. That is not a gap on one button. It is evidence that nobody has
turned VoiceOver on. The fix is cheap; the habit is the expensive part.
What I got wrong
Four of my first-pass leads did not survive reading the caller.
I was ready to file a dictated-text race as a serious data-loss bug. Then I grepped for callers and found none: the flagged file is dead, and the live voice path is a sibling. A would-be S1 became an S3 that says "delete this." I flagged a keyboard hijack in a 674-line component that turns out never to be mounted; a twenty-line entry point cleared it. And I expected a modal to discard an in-progress edit on Escape, until I walked the event order and found the commit-on-blur convention actually covers it — it still never tells the user the blur saved anything, so a smaller finding survived.
My seat filed 260 findings and not one S0. Seven S1s. The honest summary is that this is not a dangerous interface — it is a truthful-looking one with an untruthful failure mode, which is a different and more fixable problem. Credit where it is due: the newer components are measurably better than the older ones — optimistic saves that revert, abort-keyed reads, an idempotency key minted per dialog open, a grid that freezes row order while you edit so the cell under your pointer does not move. Those come from watching a real person use a screen. The debt is concentrated, not spread.
The transferable lesson
Three honest states — loading, empty, error — is a slogan every team nods at. Counting them across a real codebase shows what happens: loading is everywhere, empty is usually there, error is the orphan. The UI's answer to failure becomes absence, and absence is indistinguishable from "you have none."
So the rule I would write instead is narrower and mechanical:
Render an empty state only when a 200 said it was empty.
Gate the empty branch on a successful response, not on a falsy variable. Make the error branch a shared component with a retry, so it is cheaper to import than to hand-roll — three private error paths is how the rule decays. And run the check where the code graph does not reach: the demo bundle a new user watches, the native app, the public share view a prospect sees. Gates follow the code graph. The user follows the pixels.
The second lesson is the one I keep relearning. A convention written for the slow path does not reach the fast path on its own. This team's "never a bare spinner" rule landed completely for AI jobs and never arrived for ordinary reads and short synchronous writes. A principle written inside one file is not a pattern. A pattern is a shared component the next screen cannot avoid.