← Shopify App BuilderCONTENT HISTORY

Update to Shopify App Builder

Snapshot Sep 30, 2026 · 23:13 UTC · version 1.4.1

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Use when designing empty states, loading states, error states, and partial-failure states in a Shopify embedded app. Covers Polaris EmptyState, SkeletonPage/SkeletonBodyText, Banner tones (critical/warning/info/success), Toast vs Banner vs Modal decision, optimistic UI in Remix, network-down handling, partial bulk-failure recipes, GraphQL '200 OK with errors' gotcha. Triggers: 'empty state', 'loading state', 'error state', 'polaris banner', 'skeleton', 'toast', 'optimistic ui', 'partial failure', 'network down', 'remix loading ux', 'graphql error handling'.",
  "included_files": [],
  "name": "ux-empty-error-states",
  "skill_md_contents": "---\nname: ux-empty-error-states\ndescription: \"Use when designing empty states, loading states, error states, and partial-failure states in a Shopify embedded app. Covers Polaris EmptyState, SkeletonPage/SkeletonBodyText, Banner tones (critical/warning/info/success), Toast vs Banner vs Modal decision, optimistic UI in Remix, network-down handling, partial bulk-failure recipes, GraphQL '200 OK with errors' gotcha. Triggers: 'empty state', 'loading state', 'error state', 'polaris banner', 'skeleton', 'toast', 'optimistic ui', 'partial failure', 'network down', 'remix loading ux', 'graphql error handling'.\"\n---\n\n# Empty, Loading & Error State UX for Shopify Polaris Apps\n\nA practical, opinionated reference for the three states that make or break a Shopify embedded app: when there's nothing yet, when something is on its way, and when something went sideways. Pulled from Polaris docs, Remix pending UI docs, and real-world patterns.\n\n---\n\n## 1. EmptyState — when, how, and what to put inside\n\n### What it is\n\n`EmptyState` is Polaris' purpose-built component for \"this page/section is empty.\" It composes a centered illustration, a heading, optional body text, and a primary action (and optional secondary action). It is intended for whole-page-empty experiences, not tiny empty slots inside a card sidebar.\n\n### When to use it\n\nUse `EmptyState` when:\n\n- A list/table/chart has zero rows for first-time merchants (no products, no orders, no campaigns).\n- A filtered/searched view returns zero matches (different copy, same component).\n- A feature requires setup before it can be used (no connected account, no plan selected).\n- A merchant landed on a page that needs onboarding context before they can act.\n\nDo **not** use `EmptyState` for:\n\n- Small empty slots inside a `Card` where a simple \"No notes yet\" sentence is enough.\n- Form fields that aren't filled in — that's just normal state.\n- Loading. Use `SkeletonPage` instead.\n- Errors. Use a `Banner` (or a full-page error view) instead.\n\n### Anatomy\n\n```tsx\nimport { EmptyState, Page } from \"@shopify/polaris\";\n\n<Page>\n  <EmptyState\n    heading=\"Manage your inventory transfers\"\n    action={{ content: \"Add transfer\", onAction: () => navigate(\"/transfers/new\") }}\n    secondaryAction={{\n      content: \"Learn more\",\n      url: \"https://help.shopify.com/manual/inventory/transfers\",\n      external: true,\n    }}\n    image=\"https://cdn.shopify.com/s/files/.../empty-state.svg\"\n  >\n    <p>Track and receive your incoming inventory from suppliers.</p>\n  </EmptyState>\n</Page>\n```\n\n### Content rules\n\n- **Heading** — a friendly sentence fragment, not a label. \"Manage your inventory transfers,\" not \"Transfers.\"\n- **Body** — one or two sentences. Explain the value, not the mechanism.\n- **Primary action** — verb-first, the single most important thing they can do. \"Add transfer,\" \"Import products,\" \"Connect Stripe.\"\n- **Secondary action** — almost always a \"Learn more\" link to Shopify docs or your own help article. Use `external: true` and the new-window icon will render.\n- **Image** — a Polaris illustration or your own brand-consistent SVG. ~400×250px works well. Polaris recommends ~40px of white space above when nested inside a `Card` or `Modal`.\n\n### Two flavors you must handle\n\n1. **First-run empty** (no data has ever existed). Copy is teaching-oriented. CTA is \"Create your first X.\"\n2. **Filtered empty** (data exists, the filter killed it). Copy is \"No results match your filters.\" CTA is \"Clear filters\" or \"Reset search.\" Do NOT show the onboarding CTA here — it confuses returning users.\n\nDistinguish them in code:\n\n```tsx\nconst showFilteredEmpty = products.length === 0 && hasActiveFilters;\nconst showFirstRunEmpty = products.length === 0 && !hasActiveFilters;\n```\n\n---\n\n## 2. SkeletonPage / SkeletonBodyText — loading patterns\n\nPolaris ships four skeleton components: `SkeletonPage`, `SkeletonBodyText`, `SkeletonDisplayText`, `SkeletonTabs`, and `SkeletonThumbnail`. The point is **perceived performance**: merchants tolerate a 600ms load that shows shape over a 200ms load that shows a blank screen.\n\n### When to use each\n\n| Component | Use for |\n|---|---|\n| `SkeletonPage` | Wrap the whole route while the loader runs. Pass `primaryAction` and `title` as booleans to render their skeleton equivalents. |\n| `SkeletonBodyText` | Multi-line text blocks. `lines={3}` default — match it to the real content's line count. |\n| `SkeletonDisplayText` | One large piece of dynamic text (a product name, an order number). `size=\"small\" | \"medium\" | \"large\"`. |\n| `SkeletonTabs` | The tab strip on a tabbed page. |\n| `SkeletonThumbnail` | Product image placeholders in lists. |\n\n### Anatomy of a skeleton route\n\n```tsx\nimport {\n  SkeletonPage,\n  Layout,\n  Card,\n  SkeletonBodyText,\n  SkeletonDisplayText,\n} from \"@shopify/polaris\";\n\nexport function ProductsSkeleton() {\n  return (\n    <SkeletonPage primaryAction title=\"Products\">\n      <Layout>\n        <Layout.Section>\n          <Card>\n            <SkeletonBodyText lines={8} />\n          </Card>\n          <Card>\n            <SkeletonDisplayText size=\"small\" />\n            <SkeletonBodyText lines={3} />\n          </Card>\n        </Layout.Section>\n        <Layout.Section variant=\"oneThird\">\n          <Card>\n            <SkeletonBodyText lines={2} />\n          </Card>\n        </Layout.Section>\n      </Layout>\n    </SkeletonPage>\n  );\n}\n```\n\n### Skeleton rules\n\n- **Use skeletons for dynamic content only.** A static page title can render its real text immediately; only the changing parts need skeletons.\n- **Match the shape.** Don't show 3 skeleton lines when the real card has 8. Merchants notice the jump.\n- **Don't combine skeletons with spinners on the same view.** Pick one.\n- **Don't skeleton for <300ms loads.** It flashes and feels broken. Use a spinner or just render nothing.\n- **Don't skeleton for >10s loads.** That's a slow query — show a progress message (\"Importing 4,200 products…\").\n\n### Remix integration\n\nIn Remix, render the skeleton when `useNavigation().state === \"loading\"` for the route you're loading into, or use a `<Suspense fallback={<Skeleton />}>` boundary around a deferred loader value.\n\n```tsx\nimport { useNavigation } from \"@remix-run/react\";\n\nexport default function Products() {\n  const navigation = useNavigation();\n  const isLoading = navigation.state === \"loading\";\n  return isLoading ? <ProductsSkeleton /> : <ProductsContent />;\n}\n```\n\n---\n\n## 3. Error banners — tone hierarchy and recovery actions\n\nPolaris `Banner` has five tones, each with semantics, color, icon, and screen-reader behavior:\n\n| Tone | Use for | A11y role | Dismissible? |\n|---|---|---|---|\n| `critical` | Blocking errors, payment failure, action impossible | `role=\"alert\"` (announced immediately) | No — only if merchant can dismiss safely |\n| `warning` | Something needs their attention soon (trial ending, deprecation) | `role=\"alert\"` | Often yes |\n| `info` | Status updates, neutral context (\"Sync in progress\") | `role=\"status\"` (announced after critical) | Yes |\n| `success` | Confirmation of a multi-step or async win | `role=\"status\"` | Yes |\n| `neutral` | Defaults, low-priority context | `role=\"status\"` | Yes |\n\n### Anatomy\n\n```tsx\n<Banner\n  tone=\"critical\"\n  title=\"Could not publish 3 products\"\n  action={{ content: \"Retry failed\", onAction: retryFailed }}\n  secondaryAction={{ content: \"View errors\", onAction: showLog }}\n  onDismiss={() => setDismissed(true)}\n>\n  <p>\n    These products failed validation: <Link url=\"/products?failed=true\">view the list</Link>.\n  </p>\n</Banner>\n```\n\n### Banner content rules\n\n- **Title** — what happened, not what to do. \"Could not publish 3 products,\" not \"Please try again.\"\n- **Body** — one sentence explaining cause if you know it. If you don't, say so honestly (\"We're not sure why\").\n- **Primary action** — always include a recovery path when one exists. \"Retry,\" \"Reconnect,\" \"Try again.\"\n- **Secondary action** — \"View details,\" \"Contact support,\" \"Learn more.\"\n- **Critical banners** for form submission errors should be placed at the top of the form, and focus should be moved to the banner programmatically when the form is submitted with errors.\n\n### Inline errors vs. banner errors\n\n- **Inline error** (`InlineError` or the `error` prop on `TextField`) — for field-level validation. Sits directly below the input. Wire `aria-describedby` to the input.\n- **Banner critical** — for form-level summary OR for errors that aren't tied to a single field (API failure, permission denied).\n\nUse both together for long forms: inline errors at each broken field + a critical banner at the top saying \"Fix 3 errors below.\"\n\n---\n\n## 4. 5xx vs 4xx UX — what to show users\n\n### 4xx — the merchant's request was bad\n\nCategories:\n\n- **400 / 422 Validation** — show inline errors on the exact fields. Banner only if there are multiple.\n- **401 Unauthenticated** — silently redirect to auth (App Bridge will usually handle this for embedded apps).\n- **403 Forbidden / scope missing** — `Banner tone=\"warning\"` with \"Reconnect\" or \"Grant permission\" action. Tell them what permission is missing. Never blame them.\n- **404 Not found** — full-page empty state with \"Back to [parent]\" action. Don't apologize, don't be cute.\n- **409 Conflict** — modal asking them to choose (\"Overwrite\" / \"Keep both\" / \"Cancel\").\n- **429 Rate limited** — `Banner tone=\"warning\"` \"Too many requests. Try again in 60 seconds.\" Show a countdown if you can. Auto-retry in the background.\n\n### 5xx — Shopify or your server is broken\n\n- **500 Generic error** — full-page error view OR `Banner tone=\"critical\"` depending on whether the page rendered. Always include a \"Retry\" button. Log the request ID and surface it in a `<details>` so support can correlate.\n- **502 / 503 / 504 Gateway / unavailable** — retry once or twice in the background, then show a banner: \"Shopify is having trouble right now. We'll keep trying.\" Link to `https://status.shopify.com`.\n\n### GraphQL caveat (Shopify Admin API)\n\nThe GraphQL Admin API can return HTTP 200 with errors in the response body. Always check `data.userErrors` (mutation user errors) and the top-level `errors` array (request errors) before treating a response as success. A 200 status is not a green light.\n\n```ts\nconst res = await admin.graphql(MUTATION, { variables });\nconst json = await res.json();\nif (json.errors?.length) throw new Error(json.errors[0].message);\nif (json.data?.productCreate?.userErrors?.length) {\n  return { ok: false, errors: json.data.productCreate.userErrors };\n}\n```\n\n### What to show, by error class\n\n| Class | Page state | Component | Tone | Recovery |\n|---|---|---|---|---|\n| Validation (400/422) | Form stays, errors inline | `TextField error` + `Banner` summary | critical | Fix inline |\n| Auth (401) | Redirect | — | — | App Bridge handles |\n| Permission (403) | Page renders, blocked card | `Banner` | warning | \"Reconnect\" action |\n| Not found (404) | Full-page empty | `EmptyState` with `image` | — | \"Back to [list]\" |\n| Conflict (409) | Modal | `Modal` | — | Choice buttons |\n| Rate limit (429) | Page renders | `Banner` | warning | Auto-retry, show countdown |\n| Server (5xx) | Depends | `Banner` or full-page error | critical | \"Retry\" + status link |\n\n---\n\n## 5. Toast vs Banner vs Modal\n\nThe three feedback patterns are not interchangeable. Pick wrong and merchants miss the message or get blocked unnecessarily.\n\n### Toast\n\n- **Purpose** — brief, non-blocking confirmation of an action. 3-second auto-dismiss.\n- **Length** — 3 words ideally, 6 max.\n- **Use for** — \"Product saved,\" \"Order archived,\" \"Settings updated,\" \"Copied to clipboard.\"\n- **Do NOT use for** — errors that need action, persistent state, anything a merchant needs to remember after they look away.\n- **Do NOT use for** — \"Internet disconnected\" if they need to do something about it. Toast is fine for the moment-of-disconnect; persistent connectivity issues belong in a banner.\n\n```tsx\nimport { Toast, Frame } from \"@shopify/polaris\";\n\n<Toast content=\"Product saved\" onDismiss={hide} />\n// Error variant:\n<Toast content=\"Could not save\" error onDismiss={hide} action={{ content: \"Retry\", onAction: retry }} />\n```\n\n### Banner\n\n- **Purpose** — persistent, in-context message that needs the merchant's attention but doesn't block the page.\n- **Use for** — form errors, billing warnings, sync status, partial failures, feature announcements, deprecation notices.\n- **Lives at** — top of page or top of section. Stays until dismissed or until the underlying condition changes.\n\n### Modal\n\n- **Purpose** — block all other interaction until the merchant makes a choice.\n- **Use for** — destructive confirmations (\"Delete 47 products?\"), conditional changes that need explicit consent, focused single-task flows (a 1-step wizard).\n- **Do NOT use for** — complex multi-step forms (use a dedicated page).\n- **Do NOT use for** — anything you could put in a banner. Modals are disruptive — reserve them.\n\n### Decision flow\n\n```\nNeed to interrupt the user? → Modal\nPersistent, needs action or attention? → Banner\nConfirmation of a thing they just did, no action needed? → Toast\n```\n\n---\n\n## 6. Optimistic update pattern in Remix\n\nOptimistic UI = updating the screen immediately based on what you know the user just submitted, before the server confirms. The merchant sees instant feedback; you reconcile when the response arrives.\n\n### When to do it\n\n- Toggles (publish / unpublish, archive / restore).\n- Quick edits with tiny payloads (rename, change tag).\n- Add-to-list actions where the new item shape is predictable.\n\n### When NOT to do it\n\n- Anything that returns data only the server knows (auto-generated IDs you display, computed totals, side-effects).\n- Anything where rollback would confuse the user (payment confirmation).\n- Slow-failing operations (file uploads where you won't know success for 30 seconds).\n\n### The pattern with `useFetcher`\n\n```tsx\nimport { useFetcher } from \"@remix-run/react\";\n\nfunction PublishToggle({ product }: { product: Product }) {\n  const fetcher = useFetcher();\n\n  // Optimistic value: if the fetcher is submitting, use what it sent.\n  const isPublished =\n    fetcher.formData\n      ? fetcher.formData.get(\"published\") === \"true\"\n      : product.published;\n\n  return (\n    <fetcher.Form method=\"post\" action={`/products/${product.id}/publish`}>\n      <input type=\"hidden\" name=\"published\" value={String(!isPublished)} />\n      <Button submit pressed={isPublished}>\n        {isPublished ? \"Published\" : \"Draft\"}\n      </Button>\n    </fetcher.Form>\n  );\n}\n```\n\n### Handling failure\n\nThe fetcher exposes `fetcher.data` once the server responds. If `data.ok === false`, render an inline error or fire a toast and let React revert (the optimistic value derived from `formData` clears when the submission finishes).\n\n```tsx\nuseEffect(() => {\n  if (fetcher.state === \"idle\" && fetcher.data?.ok === false) {\n    showToast({ content: \"Could not publish\", error: true });\n  }\n}, [fetcher.state, fetcher.data]);\n```\n\n### List add/remove with `useFetchers`\n\nTo handle multiple in-flight optimistic actions at once (e.g., bulk publishing), `useFetchers()` returns every active fetcher. You can merge their `formData` into your rendered list to show pending items immediately.\n\n---\n\n## 7. Network-down behavior\n\n### Detection\n\n`navigator.onLine` and the `online`/`offline` window events are your starting point — but `onLine === true` only means the device is on *some* network, not that it can reach your server. Verify with a real ping (a HEAD to your `/healthz` or a cheap GraphQL query) when it matters.\n\n```ts\nuseEffect(() => {\n  const handleOnline = () => verifyAndResume();\n  const handleOffline = () => setOffline(true);\n  window.addEventListener(\"online\", handleOnline);\n  window.addEventListener(\"offline\", handleOffline);\n  return () => {\n    window.removeEventListener(\"online\", handleOnline);\n    window.removeEventListener(\"offline\", handleOffline);\n  };\n}, []);\n```\n\n### The three-step UX\n\n1. **Detect and inform** — small persistent banner at the top: \"You're offline. Some features won't work.\"\n2. **Queue or block** — for read-only views, let them keep browsing cached data. For writes, disable mutate buttons and show a tooltip (\"Save when reconnected\"). If you support background queueing (rare in admin apps), tell them: \"Changes will sync when you're back online.\"\n3. **Reassure when back** — toast: \"Back online.\" If you queued anything, fire it and show a banner: \"Syncing 3 pending changes…\" → \"All changes saved.\"\n\n### Rules\n\n- Never use a modal for connectivity. It blocks all interaction including the retry attempt.\n- Use a banner (`tone=\"warning\"`) for persistent offline state.\n- A toast is appropriate for the moment of disconnect (\"Internet disconnected\") but not for the persistent condition.\n- Don't trigger destructive cleanup on disconnect. The connection might come back in 2 seconds.\n\n---\n\n## 8. Partial failure — \"7 of 10 products imported\"\n\nThis is one of the most under-handled states in Shopify apps. A bulk operation rarely either succeeds completely or fails completely — it almost always succeeds for some items and fails for others. Treat partial success as a first-class state, not an edge case.\n\n### The pattern\n\n```tsx\n<Banner\n  tone=\"warning\"\n  title=\"Imported 7 of 10 products\"\n  action={{ content: \"Retry failed\", onAction: retryFailed }}\n  secondaryAction={{ content: \"Download error report\", onAction: downloadCsv }}\n>\n  <p>3 products could not be imported. Common cause: missing SKU.</p>\n  <List type=\"bullet\">\n    <List.Item>Acme Widget — duplicate handle</List.Item>\n    <List.Item>Beta Gadget — missing price</List.Item>\n    <List.Item>Gamma Tool — invalid weight unit</List.Item>\n  </List>\n</Banner>\n```\n\n### Rules\n\n- **Count the wins first.** \"Imported 7 of 10,\" not \"Failed to import 3 of 10.\" Merchants need to know what worked before they fix what didn't.\n- **List the failures with reasons.** Up to 10 inline. Beyond that, offer a CSV download.\n- **Single retry action.** Re-process only the failed items, not all 10.\n- **Don't auto-dismiss.** This is a persistent banner until merchant acknowledges.\n- **Preserve order in retries.** Retried failures should appear in the same place if they succeed on second pass.\n\n### Data shape\n\nReturn both arrays from your action so the UI can show both:\n\n```ts\nreturn json({\n  succeeded: [{ id, handle }, ...],\n  failed: [{ row, reason, payload }, ...],\n});\n```\n\n---\n\n## 9. Fifteen state UX rules\n\nA condensed cheat sheet to keep next to your editor.\n\n1. **Every page has 5 states.** Empty, loading, partial, error, and full. Design all five before shipping.\n2. **First-run empty is different from filtered empty.** Different copy, different CTAs.\n3. **Skeleton for dynamic content, not static.** A static title can render immediately.\n4. **No skeleton under 300ms.** It flashes.\n5. **No spinner over 10 seconds.** That's a slow process — show progress.\n6. **Critical banners get screen-reader priority.** Use `tone=\"critical\"` only when it really is.\n7. **Toast = confirmation, Banner = condition, Modal = blocker.** Don't mix them up.\n8. **Inline errors live with their field.** Banners summarize.\n9. **Every error has a recovery action.** \"Retry,\" \"Reconnect,\" \"Contact support,\" \"Go back.\"\n10. **A 200 from GraphQL is not a green light.** Check `userErrors` and `errors`.\n11. **Optimistic UI only when you can predict the result.** No optimistic IDs.\n12. **Failure rollback must be silent unless the user needs to act.** Toast on revert, not a modal.\n13. **Partial failure is the default outcome of bulk ops.** Design for it.\n14. **Verify connectivity with a fetch, not just `navigator.onLine`.**\n15. **Tell merchants what to do, not just what happened.** \"Reconnect Stripe\" beats \"Stripe error.\"\n\n---\n\n## 10. Concrete recipes\n\n### Recipe A — Empty product list (first run)\n\n```tsx\nimport { Page, EmptyState } from \"@shopify/polaris\";\nimport { useNavigate } from \"@remix-run/react\";\n\nexport function EmptyProductList() {\n  const navigate = useNavigate();\n  return (\n    <Page title=\"Products\">\n      <EmptyState\n        heading=\"Start by adding your first product\"\n        action={{ content: \"Add product\", onAction: () => navigate(\"/products/new\") }}\n        secondaryAction={{\n          content: \"Import from CSV\",\n          onAction: () => navigate(\"/products/import\"),\n        }}\n        image=\"/empty-products.svg\"\n      >\n        <p>Products you add will show up here. You can also import a CSV.</p>\n      </EmptyState>\n    </Page>\n  );\n}\n```\n\n### Recipe B — Empty orders (filtered)\n\n```tsx\n<EmptyState\n  heading=\"No orders match these filters\"\n  action={{ content: \"Clear filters\", onAction: clearFilters }}\n  image=\"/empty-search.svg\"\n>\n  <p>Try widening your date range or removing tags.</p>\n</EmptyState>\n```\n\nNotice: no onboarding CTA. The merchant has orders — the filter just hid them.\n\n### Recipe C — Failed API call (single action)\n\n```tsx\nimport { Banner } from \"@shopify/polaris\";\n\nfunction ProductSyncCard({ fetcher }: { fetcher: FetcherWithComponents<any> }) {\n  const failed = fetcher.state === \"idle\" && fetcher.data?.error;\n  if (!failed) return <SyncButton fetcher={fetcher} />;\n\n  return (\n    <Banner\n      tone=\"critical\"\n      title=\"Could not sync products\"\n      action={{ content: \"Try again\", onAction: () => fetcher.submit(null, { method: \"post\" }) }}\n      secondaryAction={{\n        content: \"Contact support\",\n        url: \"mailto:support@yourapp.com?subject=Sync%20failed\",\n      }}\n    >\n      <p>\n        Shopify returned an error. Request ID:{\" \"}\n        <code>{fetcher.data.requestId}</code>\n      </p>\n    </Banner>\n  );\n}\n```\n\n### Recipe D — Slow query (long-running export)\n\nFor operations expected to take more than a few seconds, swap the skeleton/spinner for a progress message and let the merchant leave the page.\n\n```tsx\n<Card>\n  <BlockStack gap=\"200\">\n    <InlineStack gap=\"200\" blockAlign=\"center\">\n      <Spinner size=\"small\" />\n      <Text as=\"p\">Generating export…</Text>\n    </InlineStack>\n    <Text as=\"p\" tone=\"subdued\">\n      This usually takes 2-3 minutes. We'll email you when it's ready — feel free to navigate away.\n    </Text>\n    <ProgressBar progress={percent} size=\"small\" />\n  </BlockStack>\n</Card>\n```\n\nFor Remix specifically, kick the work off in an action that enqueues a background job, return immediately, and poll status via a `useFetcher` set on a 5-second interval. Don't tie up a request for 3 minutes.\n\n### Recipe E — Bulk import with partial failure\n\n```tsx\nfunction ImportResult({ result }: { result: ImportResult }) {\n  if (result.failed.length === 0) {\n    return (\n      <Banner tone=\"success\" title={`Imported ${result.succeeded.length} products`} />\n    );\n  }\n  return (\n    <Banner\n      tone=\"warning\"\n      title={`Imported ${result.succeeded.length} of ${result.succeeded.length + result.failed.length} products`}\n      action={{ content: \"Retry failed\", onAction: () => retry(result.failed) }}\n      secondaryAction={{\n        content: \"Download error CSV\",\n        onAction: () => downloadCsv(result.failed),\n      }}\n    >\n      <List type=\"bullet\">\n        {result.failed.slice(0, 5).map((f) => (\n          <List.Item key={f.row}>\n            Row {f.row}: {f.reason}\n          </List.Item>\n        ))}\n        {result.failed.length > 5 && (\n          <List.Item>…and {result.failed.length - 5} more</List.Item>\n        )}\n      </List>\n    </Banner>\n  );\n}\n```\n\n### Recipe F — Offline indicator\n\n```tsx\nimport { Banner, Frame, Toast } from \"@shopify/polaris\";\n\nfunction OfflineBanner({ offline }: { offline: boolean }) {\n  if (!offline) return null;\n  return (\n    <Banner tone=\"warning\" title=\"You're offline\">\n      <p>Some actions are disabled until you reconnect.</p>\n    </Banner>\n  );\n}\n```\n\nPair with a toast when state flips:\n\n```tsx\nuseEffect(() => {\n  if (justReconnected) showToast({ content: \"Back online\" });\n}, [justReconnected]);\n```\n\n### Recipe G — 404 page\n\n```tsx\n<Page>\n  <EmptyState\n    heading=\"We couldn't find that product\"\n    action={{ content: \"Back to products\", onAction: () => navigate(\"/products\") }}\n    image=\"/empty-404.svg\"\n  >\n    <p>It may have been deleted or the link may be wrong.</p>\n  </EmptyState>\n</Page>\n```\n\n### Recipe H — Form with both inline errors and a banner\n\n```tsx\n<Form method=\"post\">\n  {actionData?.errors && (\n    <Banner tone=\"critical\" title=\"Fix the errors below\">\n      <p>{actionData.errors.length} fields need your attention.</p>\n    </Banner>\n  )}\n  <TextField\n    label=\"Product title\"\n    value={title}\n    onChange={setTitle}\n    error={actionData?.errors?.title}\n    autoComplete=\"off\"\n  />\n  <TextField\n    label=\"Price\"\n    value={price}\n    onChange={setPrice}\n    error={actionData?.errors?.price}\n    autoComplete=\"off\"\n    type=\"currency\"\n  />\n</Form>\n```\n\nMove focus to the banner on submit-with-errors so screen-reader users hear the summary first:\n\n```tsx\nconst bannerRef = useRef<HTMLDivElement>(null);\nuseEffect(() => {\n  if (actionData?.errors) bannerRef.current?.focus();\n}, [actionData]);\n```\n\n---\n\n## Sources\n\n- [Empty state — Shopify Polaris React](https://polaris-react.shopify.com/components/layout-and-structure/empty-state)\n- [Skeleton page — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/skeleton-page)\n- [Skeleton body text — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/skeleton-body-text)\n- [Skeleton tabs — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/skeleton-tabs)\n- [Banner — Shopify Polaris React](https://polaris-react.shopify.com/components/feedback-indicators/banner)\n- [Error messages — Shopify Polaris React](https://polaris-react.shopify.com/content/error-messages)\n- [Inline error — Shopify Polaris React](https://polaris-react.shopify.com/components/selection-and-input/inline-error)\n- [Toast — Shopify Polaris React](https://polaris-react.shopify.com/components/deprecated/toast)\n- [Modal — Shopify Polaris React](https://polaris-react.shopify.com/components/deprecated/modal)\n- [Index table — Shopify Polaris React](https://polaris-react.shopify.com/components/tables/index-table)\n- [useFetcher — Remix](https://remix.run/docs/en/main/hooks/use-fetcher)\n- [useNavigation — Remix](https://remix.run/docs/en/main/hooks/use-navigation)\n- [Pending and Optimistic UI — Remix](https://remix.run/docs/en/main/discussion/pending-ui)\n- [Shopify API Response Statuses and Error Codes](https://www.cleverence.com/articles/shopify-dev-documentation/shopify-api-response-status-and-error-codes-5831/)\n- [Offline UX design guidelines — web.dev](https://web.dev/articles/offline-ux-design-guidelines)\n- [Fixing what's broken: in-product error messages — Shopify Design](https://medium.com/shopify-ux/fixing-whats-broken-how-to-improve-your-in-product-error-messages-f723508055bc)\n"
}

SHA-256 of public snapshot: 354e76d21af274c8ea912336edf901ec51e420dd3063b2758d0d5f5ff41541a2