# Async UX States

> 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).

- Skill: `jaykim88/async-ux-states` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/async-ux-states`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/async-ux-states/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/async-ux-states

---


# 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

1. **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

2. **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

3. **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"

4. **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

5. **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

6. **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

7. **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

8. **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
- [ ] Every async view has loading / error / empty states
- [ ] Fetches have a timeout — no infinite spinners
- [ ] Error action matches error type (no "Retry" on 401/403/404); retry uses backoff
- [ ] Errors announced (`role="alert"`/`aria-live`) and focusable
- [ ] Custom 404 and 500 pages implemented
- [ ] `error.tsx` placed at correct segment (not same as throwing layout)
- [ ] Suspense boundaries are granular (no full-page blocking)
- [ ] Sentry "blank screen" errors = 0 in last week

## 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.

