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.
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.
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
isPendingfor “no data yet” andisFetching/isRefetchingfor 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.
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
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; usearia-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.