---
name: component-4-state-model
description: Every component ships with four visual states — empty, loading, error, ready — from day 1. No spinners without skeletons.
when_to_use: You're designing or authoring a component that displays data. Frontend + mobile teams pin this.
tags: [frontend, design, ux]
---
# The four-state model
Every component that renders remote data has FOUR distinct visual states. Ship all four in the same PR — you can't "add empty state later" without shipping broken UX in the interim.
## The states
1. **Empty** — the request succeeded and returned zero items, OR the entity hasn't been created yet.
2. **Loading** — the request is in flight. Show a **skeleton** matching the eventual layout, never a spinner in isolation.
3. **Error** — the request failed. Show what went wrong + a recovery affordance (retry, contact support, or the fallback action).
4. **Ready** — data is here, render it.
## The skeleton rule
- Skeletons should match the ready state's LAYOUT within 5%. A wide card skeleton that resolves to a narrow list is disorienting.
- Skeletons pulse subtly (`animate-pulse` in Tailwind is fine). No spinners layered on top.
- Skeleton for lists shows **3 items** — enough to convey shape, not so many the eye reads them as content.
## The empty state rule
- Explain what would fill this component + the action to make it happen.
- CTA button placed prominently, not buried.
- Emojis / illustrations are fine but the copy carries the meaning.
Example (from the missions canvas):
> **No missions yet.** Pick a workflow template to get started. → `[Start a mission]`
## The error state rule
- Show the human-friendly reason (never the raw stack). "Couldn't reach the server. Retry?" not "TypeError: undefined is not a function".
- Retry button that actually retries the same request, not a page reload.
- If the error is auth (401/403), the affordance is "sign in", not "retry".
- Log the raw error to observability (Sentry, PostHog, etc.) so it's investigable. Users see the human message.
## Implementation shape (React 19)
```tsx
export function ThingList() {
const { data, error, isLoading, refetch } = useThings();
if (isLoading) return ;
if (error) return ;
if (!data?.length) return ;
return <>{data.map((t) => )}>;
}
```
## Anti-patterns
- **Empty state = "No data."** Do the work; explain what fills it and how.
- **Spinner-only loading state.** Nothing tells the user what's coming.
- **`data && data.length && data.map(...)`** without any branch for the falsy cases. That renders NOTHING and the user thinks the app is broken.
- **`toast.error(err.message)`** as your entire error handling. Toasts are ephemeral; a persistent error state is what tells the user the component is broken.