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