How would you handle loading indicators or fallback UI during the loading process
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.
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
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:
<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. - Accessibility —
aria-busy,aria-liveso 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.