VibeWeek
Home/Grow/Empty States, Loading Skeletons & Error States

Empty States, Loading Skeletons & Error States

⬅️ Day 6: Grow Overview

If you're building B2B SaaS in 2026, your product has hundreds of UI states beyond "happy path." Initial empty (no data yet), filtered empty (data exists but filter excludes), loading (data fetching), error (network failed), partial loading (some loaded), permission denied, rate limited. Most products handle the happy path beautifully and the edge cases poorly. The naive approach: a spinner for loading, an "Oops" for errors, blank for empty. The structured approach: every component has 5+ explicit states with intentional design — and that's what separates "feels polished" from "feels rough." (For application-level error pages like 404 / 500, see error-handling-error-pages-chat.md. This guide is about per-component states.)

1. Inventory the states every component has

Map states for any data-displaying component.

Standard states (5):
1. Loading — fetching data first time
2. Initial empty — no data exists for user yet
3. Filtered empty — data exists but current filter excludes
4. Error — failed to fetch / API error
5. Happy path — data displayed normally

Often-skipped states (5):
6. Partial loading — first page loaded; loading more
7. Stale + refetching — showing cached, fetching fresh
8. Permission denied — user lacks access
9. Rate-limited — too many requests
10. Disabled / locked — feature not enabled for this plan

State machine:
- Use XState or simple discriminated union
- Loading states have substates (initial vs refetch)
- Errors have subtypes (network / 4xx / 5xx)

For [COMPONENT], output:
1. State enumeration
2. Transitions between states
3. Visual treatment for each
4. Accessibility (aria-busy, aria-live announcements)
5. Recoverability (retry button, refresh, etc.)

The skipped-state cost: a "Save" button that goes from idle → loading without showing failure-state-with-retry leaves users guessing. Inventory all states explicitly.

2. Loading skeletons — match the final shape

Skeletons are not generic gray boxes. They mirror the final UI's layout to avoid jarring transitions.

Build loading skeletons.

Principles:
- Same dimensions as final content (no layout shift)
- Same row count (or at least 3-5 placeholder rows)
- Subtle shimmer (linear gradient animation 1-2 sec)
- Respect reduced-motion (disable shimmer if user prefers)
- Match dark mode

Implementation:
- shadcn/ui Skeleton component (Tailwind class with animate-pulse)
- React-loading-skeleton library (mature)
- Custom: div with bg-gray-200 + animate-pulse class

Anti-patterns:
- Skeleton too thin (rectangles 1px tall)
- Skeleton dramatically different from final shape (causes layout jump on load)
- Spinner in place of skeleton for content (provides no shape preview)
- Showing skeleton longer than 1 second when fast network is available (suspense delay)

Suspense / delay:
- Don't show skeleton instantly (flash for fast loads)
- Use suspense delay (e.g., 200ms minimum before skeleton appears)
- Don't show skeleton longer than 5 seconds (graceful timeout to error state)

For [COMPONENT_TYPE]:
- Table: 5 rows with column-shape skeletons
- Card grid: 3-6 card skeletons matching final card layout
- List: 3-5 list-item skeletons
- Detail page: header skeleton + body skeleton matching final shape
- Chart: chart-shaped skeleton (rectangle with subtle peaks)

Output:
1. Skeleton component
2. Per-component-type variants
3. Suspense delay logic
4. Reduced-motion handling
5. Storybook entry

The "spinner instead of skeleton" trap: spinners say "loading" without showing what's coming. Skeletons say "loading + here's the shape." Skeletons feel ~30% faster perceptually.

3. Empty states — initial vs filtered are different

The single most-violated empty-state rule: rendering the same "Add item" CTA whether the user has 0 items or 1000 items filtered.

Build empty states.

Initial empty (user has zero items ever):
- Friendly illustration or icon
- Clear primary CTA: "Create your first project"
- Onboarding hint: 1-2 sentences explaining value
- Optional: example data, video tutorial, doc link

Filtered empty (user has items but filter returns zero):
- "No projects match your filter"
- Clear "Clear filters" button
- Show what filter is applied: "Status: Active, Owner: Me"
- DON'T show "Create project" CTA (irrelevant to user's intent)

Search empty (user typed query, no matches):
- "No results for '[query]'"
- Suggestions: similar terms, recent searches
- "Clear search" button

Pre-empty (waiting for action):
- "Awaiting upload" / "Pending data sync"
- Different illustration than empty-by-default

Anti-patterns:
- Same empty state for all (loses context)
- Unclear next action (user lost)
- Heavy illustration that takes seconds to load
- Empty state that's so big it pushes content below fold

For [COMPONENT], output:
1. State variants (initial / filtered / search / pre)
2. CTA per state
3. Illustration choice (or icon if simpler)
4. Microcopy for each
5. Mobile-friendly (don't take entire screen)

The "clear filters" affordance: filtered empty without a "clear filters" button forces users to scroll back, find the filter, hunt for the "X" to clear. Add the button right in the empty state.

4. Error states — specific, actionable, recoverable

Generic "Something went wrong" is the worst error message. Be specific.

Build error states.

Specific error types:

Network error (no connection):
- "You're offline. Check your connection."
- Retry button (auto-retry when connection returns)

Server error (5xx):
- "Server problem. We're looking into it."
- Retry button + report-issue link
- Optional: status page link

Authentication error (401):
- "Session expired. Sign in again."
- Sign-in button
- Auto-redirect after 3 seconds

Authorization error (403):
- "You don't have access to this."
- "Request access" button if applicable
- Contact-admin hint

Not found (404):
- "We couldn't find that."
- Back-button or breadcrumb to safety

Rate limited (429):
- "Too many requests. Try again in 30s."
- Countdown if rate-limit info available
- Don't auto-retry instantly

Validation error (400 with field errors):
- Inline at field level (not modal)
- Specific message per field
- Don't lose user input

Component-level error (within page):
- Compact error treatment
- Doesn't take down the whole page
- "This widget couldn't load. [Retry]"

Generic fallback (last resort):
- "Something went wrong. We're investigating."
- Send debug info to error-tracking (Sentry / Bugsnag)
- Offer Reload / Contact-support

For [COMPONENT], output:
1. Error types you might see
2. Per-type message + action
3. Recovery affordance (retry / sign-in / contact)
4. Observability (log error to Sentry with context)
5. User-input preservation (don't lose form data)

The "Something went wrong" generic: useless. "Couldn't save: company name is too long (max 100 chars)" is actionable.

5. Partial states — keep what loaded

When some data loads + some fails, partial states matter.

Build partial-loading states.

Pattern: dashboard with multiple widgets.
- Each widget loads independently
- One widget fails → others stay loaded
- Failed widget shows compact error: "Couldn't load. [Retry]"

Pattern: paginated list.
- Page 1 loads; page 2 fails to load
- Show page 1 data + error banner: "Couldn't load more. [Retry]"

Pattern: nested data.
- Parent loaded; nested children failed
- Show parent + placeholder children: "Loading children..." or "[Retry]"

Pattern: stale + refresh.
- Show cached data with "Last updated 5 min ago"
- Subtle "Refresh" affordance
- Show fresh-fetch in background; replace when ready

Pattern: optimistic + rollback.
- User submits action; UI updates immediately
- Network call fails → rollback + toast: "Couldn't save. [Retry]"

Anti-patterns:
- One failure blanks entire dashboard
- Loading spinner over already-loaded data (jarring)
- Silent failure (user thinks it worked)

Implementation:
- TanStack Query / SWR with per-query error states
- Component-level error boundaries (React)
- Toast for transient errors; inline for persistent

Output:
1. Partial-state patterns for [PAGE]
2. Per-widget independence
3. Error boundary placement
4. Stale-data handling
5. Optimistic-update rollback

The error-boundary discipline: wrap each independent widget in its own error boundary. One widget crashing shouldn't crash the dashboard. Default React error boundaries make this easy.

6. Loading durations + perceived performance

How long users wait shapes their perception more than actual speed.

Optimize perceived loading performance.

Latency thresholds (Nielsen):
- 0-100ms: feels instant
- 100ms-1s: feels responsive (keep simple feedback)
- 1-10s: feels slow (require progress + reassurance)
- 10s+: feels broken (require detailed progress + ability to abandon)

Tactics by duration:

<100ms:
- No loading state needed
- Direct render

100-1000ms:
- Subtle skeleton or spinner
- Don't show before 100ms (avoid flash)

1-5 seconds:
- Skeleton with shimmer
- "Loading..." text
- Optional: explanation ("Crunching numbers")

5-30 seconds:
- Progress bar (estimated)
- Show partial data as it arrives (streaming)
- Reassurance: "This usually takes 20 seconds"
- Don't auto-dismiss; let user navigate away if they want

30s+:
- Background job pattern
- Show "We'll email you when ready"
- Or: notification bell when complete
- User shouldn't have to wait on screen

Optimistic patterns:
- Show skeleton instantly; assume success
- Show stale data while refreshing
- Optimistic mutations (assume save succeeded; rollback on failure)

Streaming patterns:
- Server-side rendering with Suspense (React 19+)
- Show first chunk fast; load rest progressively
- LLM-style token streaming for AI features

For [INTERACTION], output:
1. Expected latency
2. Loading strategy
3. Progress indication
4. Abandonment / background-job threshold
5. Streaming opportunities

The Suspense-streaming pattern in 2026: React 19 + Next.js 16 stream UI from server in chunks. Above-fold content paints in <500ms; below-fold streams in. Architecturally hard but visibly faster.

7. Toast for transient, inline for persistent

Where you show errors matters as much as what you show.

Decide where to show feedback.

Toast (top-right or bottom-right):
- Transient feedback (5-10 seconds)
- Success messages, non-critical info
- Errors that don't need recovery (background sync failed)
- Auto-dismiss
- See toast-notifications-ui-chat for details

Inline (within the component / form):
- Field-level validation errors
- Component-level errors that block interaction
- Messages user must address

Modal (intrusive):
- Critical errors blocking user
- "Session expired" forcing sign-in
- Destructive action confirmation
- Use sparingly

Banner (top of page):
- Persistent system status
- "Maintenance mode" / "Trial expiring in 3 days"
- Dismissible but stays during session

Empty state CTA:
- When no data and action needed
- See empty-state guidance above

Anti-patterns:
- Toast for critical errors (auto-dismisses; user misses)
- Modal for trivial info (interruptive)
- Inline for global errors (wrong scope)
- Banner for transient feedback (sticks around)

For each error type, output:
1. Display location (toast / inline / modal / banner)
2. Persistence (auto-dismiss / sticky / dismissible)
3. Severity treatment (color, icon)
4. Action required from user
5. Accessibility (aria-live for transient)

The toast-vs-inline rule: if user can fix it inline, show inline. If user just needs to know, toast. If user must act before continuing, modal.

8. Accessibility — announce state changes

State changes are silent for screen-reader users unless you announce them.

Make state changes accessible.

Loading announcements:
- aria-busy="true" on container during fetch
- aria-live="polite" region announces "Loading projects"
- On complete: announce "5 projects loaded"
- On error: announce "Couldn't load projects. Retry available."

Empty state:
- Heading announces empty state
- aria-live announcement when filter empties result

Error state:
- aria-live="assertive" for critical errors
- role="alert" for actionable errors
- Focus moves to error message (or first error field) for forms

Skeleton:
- aria-busy="true"
- Don't announce skeleton content to screen readers
- Real content announced when ready

Dynamic content:
- aria-live regions to announce changes
- Polite (waits for pause) vs assertive (interrupts)

Common failures:
- Spinner with no aria-live (silent loading)
- Error appears but no focus management (sighted user sees; SR user lost)
- Skeleton announced as "Skeleton skeleton skeleton" (aria-hidden on those)

Output:
1. ARIA strategy per state
2. Live-region placement
3. Focus-management rules
4. Test with VoiceOver / NVDA
5. Keyboard-only navigation through states

The "screen reader silently sees spinner" trap: very common. Add aria-live="polite" announcement of state transitions.

9. Reduced motion and preferences

Some users prefer minimal animation.

Respect motion preferences.

Detect:
- @media (prefers-reduced-motion: reduce)
- Apply via Tailwind: motion-reduce: prefix
- React: useReducedMotion hook (Framer Motion)

Adjust:
- Skeleton: replace shimmer with static gray
- Toast: reduce slide animation; keep fade
- Modals: skip slide-in; instant appear
- Loading spinners: keep but slow down

Other preferences:
- prefers-color-scheme: dark / light (see dark-mode-implementation-chat)
- prefers-contrast: high (boost contrast)
- forced-colors (Windows high-contrast mode)

Output:
1. Motion-reduction strategy per component
2. Tailwind motion-reduce: utilities
3. Spinner / shimmer alternatives
4. Test with system preference toggled

The motion-reduce: prefix in Tailwind is the simplest path. Apply to every animation utility.

10. Build a state-state library

Common patterns become components.

Build a reusable state-state component library.

Components:
- <LoadingState /> — variants for skeleton vs spinner
- <EmptyState /> — variants for initial / filtered / search
- <ErrorState /> — variants per error type
- <DataView /> — wrapper that picks the right state

Composition pattern:
<DataView
  data={data}
  isLoading={isLoading}
  error={error}
  isEmpty={data.length === 0}
  filterApplied={filterApplied}
>
  <ProjectList projects={data} />
</DataView>

DataView handles:
- isLoading → <LoadingState />
- error → <ErrorState error={error} />
- isEmpty + filterApplied → <FilteredEmpty />
- isEmpty → <InitialEmpty />
- happy → render children

Storybook:
- Story per state for every component
- Designers + engineers see all states
- Acceptance criteria: every state must be designed

When to extract:
- Pattern repeats in 3+ places
- Variants share core structure with content differences
- Cross-team consistency desired

Output:
1. Core state-state components
2. DataView composition wrapper
3. Storybook stories per state
4. Design tokens for states (illustration, color)
5. Adoption strategy (replace ad-hoc states gradually)

The DataView pattern: encapsulates the conditional render so every list/grid/dashboard handles all states correctly by default. Start with one component, replicate.

What Done Looks Like

A v1 state-handling system for B2B SaaS in 2026:

  • Every data-fetching component has 5+ explicit states (loading / initial empty / filtered empty / error / happy)
  • Skeletons match final-content shape (no layout shift)
  • Empty states differentiate initial vs filtered with right CTA
  • Error states are specific (not "Something went wrong")
  • Partial states preserve what loaded
  • Toast for transient; inline for persistent; modal for critical
  • aria-live announcements for state transitions
  • Reduced-motion respected
  • Reusable state-state component library

Add later when product is mature:

  • Streaming UI (React 19 Suspense)
  • Optimistic updates with rollback
  • Custom illustrations per empty state
  • A/B test empty-state CTAs (conversion impact)

The mistake to avoid: shipping the happy path first; deferring states. The "we'll add empty state later" plan never executes. Define states up front.

The second mistake: "Something went wrong" generic errors. Useless to user; useless to support. Be specific or fix the root cause.

The third mistake: filtered empty showing "Add item" CTA. User has items; filter excluded them. The CTA is "Clear filters" not "Add item."

See Also