renderpx
Theme: auto

Loading States

Show the user that data is loading without blank screens or layout shift. Skeletons, spinners, and the right flags (pending vs refetching).

The problem I keep seeing

Components that fetch data need to show something while the request is in flight. A blank area feels broken; a generic spinner in the center causes layout shift when content finally appears. Users don’t know whether the app is working or stuck. You need a loading state that communicates “content is coming” and, ideally, preserves the shape of the final UI so the page doesn’t jump.

The other trap: treating every fetch the same. Initial load (no data yet) is different from a background refetch (stale data is already on screen). For the first, a full skeleton or spinner is appropriate; for the second, a small indicator or no change (stale-while-revalidate) is often better.

Naive approach

One boolean loading: if true, render a spinner; otherwise render the content. Simple, but the spinner is generic and the layout shifts when data arrives.

tsx
Loading...

First improvement

Replace the spinner with a skeleton: placeholder blocks that match the approximate layout of the final content (avatar, title, lines of text). Use animate-pulse (or equivalent) so it’s clearly a placeholder. The container keeps the same size and structure, so there’s no jump when real data appears.

tsx
Loading...

Why this helps: Users see that something is loading in the right place. Perceived performance improves because the layout is stable and the brain anticipates content.

Remaining issues

  • Initial vs refetch: When data is already on screen and you’re refetching (e.g. after a mutation or refetchInterval), you usually don’t want a full skeleton. Use isPending for “no data yet” and isFetching / isRefetching for background updates.
  • Error state: Loading isn’t the only state—you need an error UI (message + retry) so the user isn’t stuck on a spinner forever if the request fails.
  • Accessibility: Announce loading to screen readers (aria-live, aria-busy) and avoid making the skeleton focusable or announced as content.

Production pattern

Use React Query (or similar) so you get isPending, isFetching, and isError. Render a skeleton when isPending; optionally show a subtle indicator when isFetching && !isPending. Always handle error with a clear message and retry. Extract the skeleton into a component that mirrors the content layout.

tsx
Loading...
Reusable skeleton componenttsx
Loading...

When I use this

  • Skeleton: When the content has a predictable layout (profile card, list item, article). Reduces layout shift and sets expectations.
  • Spinner: When the loading area is small (button, inline action) or the final layout is unknown. Prefer a spinner that doesn’t collapse the layout (e.g. same height as content).
  • No indicator for refetch: When showing stale data and refetching in the background, often no extra UI is best; the data updates when the refetch completes.

Suspense

With React Suspense, you get a declarative loading boundary: wrap the component that reads async data in Suspense and provide a fallback. The same UX rules apply—prefer a fallback that matches the content layout where possible.

Gotchas

  • Don’t skeleton everything: For a whole page, one coherent skeleton is better than five different ones that pop in at different times unless you’re intentionally streaming sections.
  • aria-busy and aria-live: Set aria-busy="true" on the loading container; use aria-live="polite" to announce “Content loaded” when done, or leave that to the app shell.
  • Button loading: For buttons that trigger a mutation, disable and show a spinner inside the button so the user can’t double-submit and sees that the action is in progress.

Data Fetching & Sync → · All patterns