---
id: state-matrix
class: b2
sourced: 2026-07-12
source: "b2b-ux-patterns ch.11"
license: open
---
# Pass: state matrix & onboarding

**Inputs:** per surface — `isListSurface`, `dataFetch`, `fetchLatency`, `compositePage`, `consumesCachedData`, `streamingView`, `optimisticMutation`, `queueSuccessIsEmpty`, `firstRunSurface`; per onboarding technique — `onboardingTechnique`, `onboardingScope` (application-level | empty-state).
**Owns:** `requiredStates`, `requiredMechanics`, `clarifications`.
**Output artifact:** the state matrix every page carries **orthogonal to its archetype** — loading, three distinct empties, degraded, stale, recovery — plus optimistic-UI rollback and the two-home classification of every onboarding technique. Archetypes describe pages as if data always arrives; this pass specifies what renders when data hasn't, can't, or shouldn't be trusted.

## The rule

**Loading is skeletons, not spinners.** Gray placeholder shapes matched to the eventual layout — matched geometry (row height, card size) so the layout doesn't jump when data lands; composite pages skeleton per region, not whole-page. Spinners are acceptable only for actions without a known destination shape.

**An empty region means three unrelated things, and each demands different treatment** — collapsing them into one "No results" string is the most common state bug in B2B. **First-run empty** — no data has ever existed here — is an onboarding surface: teach the object's value, offer both a create path and a sample-data path; it is the highest-attention real estate a new account sees. **Filtered-to-zero** — data exists but the filter excludes all of it — is a compact message that restates the active filters with one-click "clear filters"; showing first-run marketing copy to a power user whose saved view returned zero reads as data loss. **Failed-to-load** — the fetch errored — is an honest error with a retry affordance, visually distinct from both empties; silence or a generic empty makes users believe their data is gone. A list surface must carry all three, distinctly.

**Degraded and stale are honest surfaces.** On composite pages, each independently-fetched region owns its own loading and error boundary: one failed widget shows an inline "couldn't load — retry" while the rest of the page stays live — the whole page never dies for one bad call. People make decisions on cached numbers, so consumed data carries a **freshness cue** — "updated 2m ago," an as-of timestamp on financial figures, a live/paused indicator on streaming views, and a manual refresh; serve cached instantly, revalidate in the background, and say so. A queue whose success metric is an empty list carries a distinct **cleared-empty** state — celebrated, not a failure.

**Optimistic UI makes its rollback visible.** Mutations apply to the UI instantly and reconcile in the background; on failure the rollback is *visible* — a toast naming what failed, the value reverting in view, and a retry that preserves the user's input. Never silently revert (the user believes the edit stuck) and never silently lose the edit. This pairs with the reversible-action undo rule: reversible actions get optimistic-apply + undo; only genuinely irreversible ones get a blocking confirm.

**Onboarding has exactly two homes, no third — classify every technique into one.** *Application-level* patterns live in the product's chrome, span sessions, and concern the whole workspace: the setup checklist (persistent, dismissible, progress-tracked, surviving across sessions until completed), product tours/coach-marks (sparing, dismissible, contextually triggered — never a forced first-login gauntlet), and sample/demo data (pre-seeded realistic records with a path to delete or convert). *Empty-state* patterns live inside a specific page's first-run empty: empty-state-as-teacher — the page explaining itself with an inline example or template gallery and both "create real" and "load sample" paths. The classification matters because the lifecycles differ: application-level patterns end when the account activates; empty-state patterns recur forever — every new module, team, or newly permissioned object presents a first-run empty to somebody. An onboarding technique whose home is undeclared cannot be placed: clarify.

```json decision-table
{"pass": "state-matrix", "rows": [
  {"when": {"dataFetch": true}, "then": {"requiredStates": {"mustInclude": ["loading"]}, "requiredMechanics": {"mustInclude": ["skeleton-matched-geometry", "per-region-skeleton"], "mustNotInclude": ["spinner-with-known-shape"]}}, "reason": "loading-is-skeletons-not-spinners"},
  {"when": {"isListSurface": true}, "then": {"requiredStates": {"mustInclude": ["empty-first-run", "empty-filtered", "failed-to-load"]}, "requiredMechanics": {"mustInclude": ["distinct-empty-treatments", "clear-filters-affordance", "retry-on-failed-load"], "mustNotInclude": ["single-no-results-string"]}}, "reason": "three-empties-are-distinct"},
  {"when": {"compositePage": true}, "then": {"requiredStates": {"mustInclude": ["degraded"]}, "requiredMechanics": {"mustInclude": ["per-region-error-boundary", "inline-retry-per-region"], "mustNotInclude": ["whole-page-dies-for-one-call"]}}, "reason": "degraded-isolates-per-region"},
  {"when": {"consumesCachedData": true}, "then": {"requiredStates": {"mustInclude": ["stale"]}, "requiredMechanics": {"mustInclude": ["freshness-cue-as-of-timestamp", "manual-refresh", "revalidate-in-background"]}}, "reason": "decisions-get-made-on-cached-numbers"},
  {"when": {"streamingView": true}, "then": {"requiredMechanics": {"mustInclude": ["live-paused-indicator"]}}, "reason": "streaming-declares-live-or-paused"},
  {"when": {"optimisticMutation": true}, "then": {"requiredMechanics": {"mustInclude": ["optimistic-apply", "visible-rollback-toast", "retry-preserves-input"], "mustNotInclude": ["silent-revert", "silent-edit-loss"]}}, "reason": "rollback-must-be-visible"},
  {"when": {"queueSuccessIsEmpty": true}, "then": {"requiredStates": {"mustInclude": ["empty-cleared"]}}, "reason": "a-cleared-queue-is-celebrated"},
  {"when": {"firstRunSurface": true}, "then": {"requiredStates": {"mustInclude": ["empty-first-run"]}, "requiredMechanics": {"mustInclude": ["empty-state-as-teacher", "create-real-path", "load-sample-path"]}}, "reason": "first-run-is-the-page-teaching-itself"},
  {"when": {"fetchLatency": true}, "then": {"requiredMechanics": {"mustInclude": ["skeleton-matched-geometry"], "mustNotInclude": ["layout-jump-on-load"]}}, "reason": "skeleton-geometry-prevents-jank"},
  {"when": {"onboardingScope": "application-level"}, "then": {"requiredMechanics": {"mustInclude": ["setup-checklist-persistent", "dismissible-contextual-tours", "sample-data-with-convert-or-delete"]}}, "reason": "app-level-onboarding-spans-sessions"},
  {"when": {"onboardingScope": "empty-state"}, "then": {"requiredMechanics": {"mustInclude": ["empty-state-as-teacher"]}}, "reason": "empty-state-onboarding-is-page-scoped"},
  {"when": {"onboardingTechnique": true, "onboardingScope": "__absent__"}, "then": {"clarifications": ["onboarding-home-unspecified"]}, "reason": "every-technique-classifies-into-one-of-two-homes"}
]}
```
