The UI State Checklist I Wish Every Prototype Had
A prototype that only shows the happy path isn't a prototype — it's a sketch. The states that break products are the ones nobody drew.
Every screen has more states than the one in the mockup. The mockup shows 6 items in a list, centered text, a filled avatar, and a green status badge. Production shows 0 items, 1 item, 400 items, a missing avatar, a loading skeleton, a network error, a permission error, and a stale timestamp from 3 days ago. The states that don't get drawn are the states that get built wrong. Here's the list I run through before I write a single component.
Loading States
There are at least three kinds:
Skeleton loaders — structural placeholders that match the layout of the loaded content. Use these when you know the shape of what's coming. A list of cards gets card-shaped skeletons. A table gets row-shaped skeletons. Don't use a spinner here; the skeleton communicates what's loading and reduces perceived wait time.
Spinner / indeterminate indicator — use when you don't know the shape of the result, or when the operation is short enough that a skeleton would flash before the real content arrives. Below ~300ms, show nothing and let the content appear. Between 300ms and 1s, consider a spinner. Above 1s, show a skeleton.
Progressive / streaming loading — data arrives in chunks. An AI response streams word by word. A large report loads sections. Show what you have. Don't block on the full payload.
The question for every loading state: does the user know what is loading and that something is loading? If they can't answer both, the state is incomplete.
Empty States
Three distinct empty states — treat them differently:
Zero state — the user has never added anything. This is an onboarding moment, not an error. Show an illustration (or not), a description of what goes here, and a primary CTA. "No projects yet. Create your first one."
Empty search results — the user searched and got nothing. Don't show the zero-state onboarding CTA here. Show what they searched for and offer to clear it. "No results for 'invoice template'. Clear search."
Empty after filter — the user applied filters and nothing matches. Show which filters are active and offer to clear them. Mixing this with empty search results is a common mistake that makes the interface feel incoherent.
Error States
Four categories, all handled differently:
Network error — the request didn't complete. Offer retry. Show a timestamp if relevant ("Last updated 3 min ago"). Don't show the error boundary's stack trace.
Validation error — the user submitted invalid input. Show the error inline, adjacent to the field that failed. Don't clear the form. Focus the first invalid field. Don't use toasts for validation errors.
Permission error — the user doesn't have access. Explain what they need access to and, if possible, who to contact or how to request it. A 403 shown as a generic "Something went wrong" is a support ticket waiting to happen.
Not found — the resource doesn't exist (or no longer exists). 404 pages that just say "Page not found" leave the user nowhere to go. Provide navigation back to a relevant parent route.
Partial Success and Optimistic Updates
Partial success happens when a bulk operation succeeds for some items and fails for others. "3 of 5 emails sent. 2 failed — retry failed." This state is almost never in the initial design and almost always in the production bug reports.
Optimistic updates show the assumed result before the server confirms. They require a rollback state. If the user archives an item and the request fails, the item must reappear in the list with an error indicator. The rollback must be visible — silently reverting state is worse than not going optimistic at all.
type ArchiveState =
| { status: "idle" }
| { status: "pending"; itemId: string }
| { status: "success"; itemId: string }
| { status: "error"; itemId: string; reason: string };
function ItemRow({ item }: { item: Item }) {
const [archiveState, setArchiveState] = useState<ArchiveState>({ status: "idle" });
async function handleArchive() {
setArchiveState({ status: "pending", itemId: item.id });
const result = await archiveItem(item.id);
if (result.ok) {
setArchiveState({ status: "success", itemId: item.id });
} else {
setArchiveState({ status: "error", itemId: item.id, reason: result.error });
}
}
if (archiveState.status === "error") {
return (
<div>
<span>{item.name}</span>
<span role="alert">Failed to archive: {archiveState.reason}</span>
<button onClick={handleArchive}>Retry</button>
</div>
);
}
return (
<div aria-busy={archiveState.status === "pending"}>
<span>{item.name}</span>
<button onClick={handleArchive} disabled={archiveState.status === "pending"}>
Archive
</button>
</div>
);
}
A discriminated union for state makes it impossible to render a component in an undefined combination (pending + error simultaneously, for example). The status field is the discriminant. TypeScript narrows the type in each branch.
Stale Data
The user loaded a record two hours ago. Another user edited it. Now the first user submits a change. What happens?
At minimum: detect the conflict. The naive implementation silently overwrites. A better one uses updatedAt timestamps or ETags to detect concurrent edits and shows a conflict state: "This record was updated by another user. Review the changes before saving."
At the UI layer: if data is cached and potentially stale, consider a visual indicator. A "last updated" timestamp with a refresh button is a low-cost way to give users confidence in the data they're looking at.
Truncation at Scale
Check your component at 1 item, 5 items, 100 items, and 10,000 items.
- At 1 item: does the layout break (a flex row that doesn't handle a single item gracefully)?
- At 100 items: does the page height become unreasonable? Is there pagination or virtualization?
- At 10,000 items: does the filter/search still feel instant? Is the list virtualized?
- At 0 items: covered above, but worth noting — test 0 explicitly, not just "a few."
Long text is a related truncation case. Names, descriptions, and labels that exceed their container need a defined behavior: truncate with ellipsis, wrap, or expand. "Truncate with ellipsis" requires a tooltip with the full value for accessibility. Wrapping requires testing the layout at 3 lines of text. Expanding requires a "show more" control.
The Checklist
Before shipping any screen:
- Loading: skeleton (shape known) or spinner (shape unknown)
- Loading: progressive if streaming
- Empty: zero-state with CTA
- Empty: empty search results (distinct from zero-state)
- Empty: empty after filter (distinct from empty search)
- Error: network — retry offered
- Error: validation — inline, adjacent to field
- Error: permission — actionable message
- Error: not found — navigation path out
- Partial success: bulk operations have per-item success/failure
- Optimistic update: rollback visible on failure
- Stale data: conflict detection or last-updated indicator
- Truncation: tested at 0, 1, many, and overlong text
This is not a comprehensive list — concurrent edit conflicts, offline states, and rate limit states are real in enough products to warrant their own sections. But these fourteen cover the states that show up in every bug queue, regardless of domain.
Draw the states or they get built wrong.