Back to React
React
medium
mid

How would you handle loading indicators or fallback UI during data fetching?

Model status as an enum (idle/loading/success/error/empty), not a boolean. Use skeleton screens over spinners for content, match the skeleton to the real layout to avoid shift, debounce indicators for fast loads, and use Suspense for code/data loading. Always handle empty and error too.

5 min read·~8 min to think through

Good loading UX is more than "show a spinner." It's about modeling states correctly and choosing the right fallback.

Model state as an enum, not a boolean

js
status: "idle" | "loading" | "success" | "error" | "empty"

A single isLoading boolean can't express "loaded but empty," "error," or "refetching in the background." An enum (or a state machine) makes every state explicit and prevents impossible combinations.

Skeleton screens > spinners (for content)

  • Skeleton screens — gray placeholder shapes mimicking the real content's layout. They communicate what's coming, reduce perceived wait, and — crucially — prevent layout shift because they occupy the same space the content will.
  • Spinners — fine for short, indeterminate actions (a button submit), but for loading a page/list they feel slower and cause a jarring swap.
  • Progress bars — when you can estimate completion.

Avoid the jank

  • Match the skeleton to the final layout — same dimensions, same structure — or content "pops in" and shifts (bad CLS).
  • Debounce the indicator for fast loads — if data usually arrives in <200–300ms, showing then instantly hiding a spinner is a worse flicker than showing nothing. Delay the indicator; if the load finishes first, the user never sees it.
  • Set a minimum display time — conversely, if you do show a spinner, a tiny minimum (~300ms) avoids a flash.

Don't forget the other states

The mark of a complete answer: loading is one of four states, and all must be designed:

  • Empty — "No results yet" with guidance, not a blank screen.
  • Error — a message and a retry action, not a dead end.
  • Success — the actual content.

Suspense for declarative loading

React.Suspense provides a declarative fallback for lazy-loaded components and (with a Suspense-enabled data layer) for data:

jsx
<Suspense fallback={<ProfileSkeleton />}>
  <Profile />
</Suspense>

It co-locates the fallback with the boundary and handles the loading state for you.

Other touches

  • Optimistic UI — for mutations, show the result immediately and roll back on error; no spinner at all.
  • Preserve old data on refetch (keepPreviousData) — don't blank the screen to re-show a skeleton.
  • Accessibilityaria-busy, aria-live so screen readers know something is loading/arrived.

The framing

"I model status as an enum — idle/loading/success/error/empty — not a boolean, so every state is explicit. For content I prefer skeleton screens that match the final layout: they reduce perceived wait and prevent layout shift, where a spinner causes a jarring swap. I debounce the indicator so fast loads don't flicker, and I always design the empty and error states — error with a retry — not just the happy path. For code or Suspense-enabled data, <Suspense fallback> makes it declarative; for mutations, optimistic UI skips the spinner entirely."

Follow-up questions

  • Why are skeleton screens often better than spinners?
  • Why debounce a loading indicator?
  • How does Suspense change how you handle loading states?
  • What's optimistic UI and when do you use it?

Common mistakes

  • Using a single isLoading boolean that can't express empty/error/refetch.
  • Skeletons that don't match the final layout — causing layout shift.
  • Spinner flicker on fast loads (show-then-immediately-hide).
  • Designing only the happy path — no empty or error state.
  • Blanking the screen on every refetch instead of keeping previous data.

Performance considerations

  • Skeletons matching layout improve CLS (a Core Web Vital). Debouncing indicators cuts visual churn. keepPreviousData avoids re-render thrash on pagination/refetch.

Edge cases

  • Data arrives faster than the indicator threshold.
  • Load succeeds but returns an empty list.
  • Slow network where the skeleton is shown for a long time.
  • Refetch/background update vs initial load.

Real-world examples

  • Facebook/LinkedIn skeleton feeds while content loads.
  • Suspense fallbacks for route-level code splitting.

Senior engineer discussion

Seniors model state as an enum/machine, choose skeletons vs spinners deliberately, debounce indicators, always design empty and error states, and bring in Suspense, optimistic UI, and keepPreviousData — plus accessibility (aria-busy/aria-live).

Related questions