Async UX States
Purpose
Every asynchronous view in the app handles the three states (loading, error, empty) completely. No white screens, no undefined exposed to users, no untrapped errors.
Universal — the 3-state pattern (loading / error / empty), custom 404/500 pages, Error Boundary placement, and Suspense-boundary granularity exist in every modern meta-framework. Only the file-naming conventions and component APIs differ.
Procedure
Audit loading states
- Find every component that fetches data (
fetch, useQuery, useSWR, Server Components)
- Verify each has a loading representation; skeleton should match the actual content size (avoid CLS)
- Delay the loader ~200–300ms so fast responses don't flash a spinner; skeleton for layout-known content, spinner for short/unknown
- On refetch (pagination, filter change), keep the previous data with a subtle loading indicator instead of blanking to a skeleton — big perceived-perf win (coordinate with
api-caching-optimization)
- Set a fetch timeout — an unresolved fetch is an infinite spinner, the white screen this skill exists to kill; after N seconds, fall through to the error state
- Mark the region
aria-busy while loading; give skeletons an SR-only "Loading…" label
Audit error states — match the action to the error type
- Every fetch path must have an error UI; message is recoverable + actionable + non-technical
- The right action depends on the error kind — a generic "Retry" is wrong for most:
- Network / offline / timeout → "Check your connection" + retry (transient)
- 401 / 403 auth → prompt sign-in or "you don't have access" — not retry
- 404 not found → navigate elsewhere; retry won't help
- 5xx server → "something went wrong" + retry + report
- Retry with bounded exponential backoff, not instant/infinite loops that hammer a failing server (frameworks pass a reset/retry fn into the error boundary — see Implementation)
- Announce the error in a
role="alert" / aria-live region and move focus to it (keyboard / SR users)
- Log to observability (Sentry, etc.) but never expose a stack trace to the user
Audit empty states
- Empty ≠ error — a successful fetch returning 0 items
- Should explain what would appear here and how to get there (CTA)
- Example: empty inbox → "No messages yet — invite teammates to start a conversation"
Place error/loading boundary files at the correct route segment
- Loading boundary: at the segment that owns the slow data fetch
- Error boundary: cannot catch errors from its same-level layout — place at the parent segment
- Root-level layout errors require a dedicated global error handler
- Not-found: at root + any segment where 404 is a valid outcome
- See Implementation for framework-specific file naming conventions
Customize 404 and 500 pages
- 404: helpful navigation back to known good routes
- 500: "something went wrong" + retry + link home
- Both branded, not default browser pages
Suspense boundary granularity
- One huge boundary at page root = entire page blocks on slowest fetch
- Multiple small boundaries = fast sections render independently
- Default: one per major content section
Error Boundary fallback UI
- Inside dynamic sections (e.g., third-party widget), wrap with a component-level error boundary
- Pair granular error boundaries with granular Suspense (step 6): one failed/slow island degrades in place instead of blanking the whole page
- Fallback offers "reload this section" or "go home"
- Falls back to safe known UI, never a stack trace
Verify (validation loop)
- For each async view, force all three states (throttle/offline the network, return
[], throw) and confirm the right UI — never a blank screen, undefined, or infinite spinner
- Confirm errors are announced and focusable, and that a slow/failed island doesn't blank the page
- In observability: the "white screen" / unhandled-error category trends to 0; track empty-state CTA click-through to validate copy
- Loop until every view passes all three states
Severity tiers
| Tier |
Examples |
Action SLA |
| Critical |
Blank/white screen or raw undefined shown; an unhandled error crashes the whole route; infinite spinner (no timeout) |
Block release; fix immediately |
| Major |
Async view missing an error or empty state; generic "Retry" shown for a 401/403/404; stack trace exposed to users; error not announced to screen readers |
Fix this sprint |
| Minor |
Spinner flash on fast responses; skeleton size mismatch (CLS); empty state without a CTA |
Schedule within 2 sprints |
Completion Criteria
Output
- 3-state components: each async view has loading / error / empty UI
- Framework special files:
loading.tsx, error.tsx, not-found.tsx, global-error.tsx placed at correct segments
- Error Boundary wrappers: around third-party widgets and dynamic sections
- Audit report (paste into PR): list of routes audited / 3-state coverage / Suspense boundary placement decisions
- Commit format:
feat(error-ux): add 3-state to <view> or fix(error-ux): correct error.tsx placement at <segment>
Implementation
React + Next.js (default)
- App Router special files:
loading.tsx / error.tsx / not-found.tsx / global-error.tsx
error.tsx cannot catch errors in its same-segment layout.tsx — place at parent segment; for root layout, only global-error.tsx catches
- Retry:
reset (or unstable_retry in Next.js 16.2+) prop passed to error.tsx
- Component-level: React
ErrorBoundary, or unstable_catchError from next/error (Next.js 16.2+, API may change) — unstable_catchError(fallback) returns a wrapper; fallback receives (props, { error, unstable_retry, reset })
- Timeout + backoff + keep-previous:
AbortSignal.timeout(ms) on fetch; TanStack Query retry + retryDelay (exponential), placeholderData: keepPreviousData to avoid blanking on refetch
- Loader delay: a delayed Suspense fallback, or CSS
animation-delay so the spinner only appears if the fetch is actually slow
- Suspense:
<Suspense fallback={...}> boundaries
Other stacks
- Vue / Nuxt:
error.vue for global errors; <NuxtErrorBoundary> for component-level; <Suspense> for async components; useFetch returns error ref for inline error state
- SvelteKit:
+error.svelte at any route level catches errors from load() and child components; +loading.svelte for streaming; goto('/') for retry after fix
- Angular: global
ErrorHandler injectable for uncaught errors; route-level resolve() errors handled by Router events; *ngIf patterns for empty states
- Universal: 3-state pattern (loading / error / empty) is framework-agnostic; the test "does refresh show the correct skeleton?" works for any stack; 404/500 pages need framework's route fallback API
Related skills
render-strategy-decision — Suspense placement is part of route strategy
observability-setup — error UI must coordinate with Sentry error capture
form-ux — form-specific errors handled there
Reference
- Key insight encoded:
error.tsx does NOT catch errors thrown in the same-segment layout.js — place error.tsx at the parent segment. Co-locate loading.tsx at the smallest subtree that does data fetching so Suspense fallbacks stay granular instead of blanking entire routes. Match the recovery action to the error type — a generic "Retry" is wrong for 401/403/404. An unresolved fetch is an infinite spinner (a white screen by another name) — always set a timeout.
1---2name: async-ux-states3description: Ensure every async view handles loading, error, and empty states correctly with proper Suspense boundaries and custom 404/500 pages. Use when QA reports blank screens, infinite spinners, or undefined exposure, or before shipping. Not for choosing route render strategy (use render-strategy-decision) or form-specific error handling (use form-ux).4license: MIT5---67# Async UX States89## Purpose10Every asynchronous view in the app handles the three states (loading, error, empty) completely. No white screens, no `undefined` exposed to users, no untrapped errors.1112**Universal** — the 3-state pattern (loading / error / empty), custom 404/500 pages, Error Boundary placement, and Suspense-boundary granularity exist in every modern meta-framework. Only the file-naming conventions and component APIs differ.1314## Procedure15161. **Audit loading states**17 - Find every component that fetches data (`fetch`, `useQuery`, `useSWR`, Server Components)18 - Verify each has a loading representation; skeleton should match the actual content size (avoid CLS)19 - **Delay the loader ~200–300ms** so fast responses don't flash a spinner; skeleton for layout-known content, spinner for short/unknown20 - **On refetch (pagination, filter change), keep the previous data** with a subtle loading indicator instead of blanking to a skeleton — big perceived-perf win (coordinate with `api-caching-optimization`)21 - **Set a fetch timeout** — an unresolved fetch is an infinite spinner, the white screen this skill exists to kill; after N seconds, fall through to the error state22 - Mark the region `aria-busy` while loading; give skeletons an SR-only "Loading…" label23242. **Audit error states — match the action to the error type**25 - Every fetch path must have an error UI; message is recoverable + actionable + non-technical26 - **The right action depends on the error kind** — a generic "Retry" is wrong for most:27 - **Network / offline / timeout** → "Check your connection" + retry (transient)28 - **401 / 403 auth** → prompt sign-in or "you don't have access" — *not* retry29 - **404 not found** → navigate elsewhere; retry won't help30 - **5xx server** → "something went wrong" + retry + report31 - Retry with **bounded exponential backoff**, not instant/infinite loops that hammer a failing server (frameworks pass a reset/retry fn into the error boundary — see Implementation)32 - Announce the error in a `role="alert"` / `aria-live` region and move focus to it (keyboard / SR users)33 - Log to observability (Sentry, etc.) but never expose a stack trace to the user34353. **Audit empty states**36 - Empty ≠ error — a successful fetch returning 0 items37 - Should explain *what* would appear here and *how* to get there (CTA)38 - Example: empty inbox → "No messages yet — invite teammates to start a conversation"39404. **Place error/loading boundary files at the correct route segment**41 - Loading boundary: at the segment that owns the slow data fetch42 - Error boundary: cannot catch errors from its same-level layout — place at the **parent** segment43 - Root-level layout errors require a dedicated global error handler44 - Not-found: at root + any segment where 404 is a valid outcome45 - See Implementation for framework-specific file naming conventions46475. **Customize 404 and 500 pages**48 - 404: helpful navigation back to known good routes49 - 500: "something went wrong" + retry + link home50 - Both branded, not default browser pages51526. **Suspense boundary granularity**53 - One huge boundary at page root = entire page blocks on slowest fetch54 - Multiple small boundaries = fast sections render independently55 - Default: one per major content section56577. **Error Boundary fallback UI**58 - Inside dynamic sections (e.g., third-party widget), wrap with a component-level error boundary59 - Pair granular error boundaries with granular Suspense (step 6): one failed/slow island degrades in place instead of blanking the whole page60 - Fallback offers "reload this section" or "go home"61 - Falls back to safe known UI, never a stack trace62638. **Verify (validation loop)**64 - For each async view, force all three states (throttle/offline the network, return `[]`, throw) and confirm the right UI — never a blank screen, `undefined`, or infinite spinner65 - Confirm errors are announced and focusable, and that a slow/failed island doesn't blank the page66 - In observability: the "white screen" / unhandled-error category trends to 0; track empty-state CTA click-through to validate copy67 - Loop until every view passes all three states6869## Severity tiers7071| Tier | Examples | Action SLA |72|---|---|---|73| **Critical** | Blank/white screen or raw `undefined` shown; an unhandled error crashes the whole route; infinite spinner (no timeout) | Block release; fix immediately |74| **Major** | Async view missing an error or empty state; generic "Retry" shown for a 401/403/404; stack trace exposed to users; error not announced to screen readers | Fix this sprint |75| **Minor** | Spinner flash on fast responses; skeleton size mismatch (CLS); empty state without a CTA | Schedule within 2 sprints |7677## Completion Criteria78- [ ] Every async view has loading / error / empty states79- [ ] Fetches have a timeout — no infinite spinners80- [ ] Error action matches error type (no "Retry" on 401/403/404); retry uses backoff81- [ ] Errors announced (`role="alert"`/`aria-live`) and focusable82- [ ] Custom 404 and 500 pages implemented83- [ ] `error.tsx` placed at correct segment (not same as throwing layout)84- [ ] Suspense boundaries are granular (no full-page blocking)85- [ ] Sentry "blank screen" errors = 0 in last week8687## Output88- **3-state components**: each async view has loading / error / empty UI89- **Framework special files**: `loading.tsx`, `error.tsx`, `not-found.tsx`, `global-error.tsx` placed at correct segments90- **Error Boundary wrappers**: around third-party widgets and dynamic sections91- **Audit report** (paste into PR): list of routes audited / 3-state coverage / Suspense boundary placement decisions92- **Commit format**: `feat(error-ux): add 3-state to <view>` or `fix(error-ux): correct error.tsx placement at <segment>`9394## Implementation9596### React + Next.js (default)97- App Router special files: `loading.tsx` / `error.tsx` / `not-found.tsx` / `global-error.tsx`98- `error.tsx` cannot catch errors in its same-segment `layout.tsx` — place at parent segment; for root layout, only `global-error.tsx` catches99- Retry: `reset` (or `unstable_retry` in Next.js 16.2+) prop passed to `error.tsx`100- Component-level: React `ErrorBoundary`, or `unstable_catchError` from `next/error` (Next.js 16.2+, API may change) — `unstable_catchError(fallback)` returns a wrapper; fallback receives `(props, { error, unstable_retry, reset })`101- Timeout + backoff + keep-previous: `AbortSignal.timeout(ms)` on `fetch`; TanStack Query `retry` + `retryDelay` (exponential), `placeholderData: keepPreviousData` to avoid blanking on refetch102- Loader delay: a delayed Suspense fallback, or CSS `animation-delay` so the spinner only appears if the fetch is actually slow103- Suspense: `<Suspense fallback={...}>` boundaries104105### Other stacks106- **Vue / Nuxt**: `error.vue` for global errors; `<NuxtErrorBoundary>` for component-level; `<Suspense>` for async components; `useFetch` returns `error` ref for inline error state107- **SvelteKit**: `+error.svelte` at any route level catches errors from `load()` and child components; `+loading.svelte` for streaming; `goto('/')` for retry after fix108- **Angular**: global `ErrorHandler` injectable for uncaught errors; route-level `resolve()` errors handled by `Router` events; `*ngIf` patterns for empty states109- **Universal**: 3-state pattern (loading / error / empty) is framework-agnostic; the test "does refresh show the correct skeleton?" works for any stack; 404/500 pages need framework's route fallback API110111## Related skills112- `render-strategy-decision` — Suspense placement is part of route strategy113- `observability-setup` — error UI must coordinate with Sentry error capture114- `form-ux` — form-specific errors handled there115116## Reference117- **Key insight encoded**: `error.tsx` does NOT catch errors thrown in the same-segment `layout.js` — place `error.tsx` at the *parent* segment. Co-locate `loading.tsx` at the smallest subtree that does data fetching so Suspense fallbacks stay granular instead of blanking entire routes. Match the recovery action to the error type — a generic "Retry" is wrong for 401/403/404. An unresolved fetch is an infinite spinner (a white screen by another name) — always set a timeout.