Overview
Global loading, error, unauthorized, and network-failure UX patterns define how a React application behaves when data is being fetched, requests fail, authentication expires, permissions are missing, or the browser cannot reach the server. These patterns decide whether users see a skeleton, inline error, toast, route error page, sign-in redirect, stale data warning, retry button, or offline banner.
This topic matters because production apps are mostly asynchronous. Real users see slow networks, expired sessions, background refetch failures, authorization changes, service outages, request timeouts, and partial data. A strong React app makes those states understandable and recoverable instead of leaving users staring at a spinner or blank page.
In interviews, this topic tests whether a candidate can distinguish local versus global errors, foreground versus background loading, authentication versus authorization, safe versus unsafe retry, and expected versus unexpected failures. It also reveals whether the candidate understands how React Router, TanStack Query, RTK Query, API clients, and error boundaries fit together.
The practical goal is to design a request state model that users can trust: clear status, preserved context, safe recovery actions, and no misleading success states.
Core Concepts
Global vs Local UX State
Not every loading or error state should be global.
Local states belong near the feature:
- A field validation error.
- A failed save button.
- A table row action failure.
- A missing optional widget.
- A background refresh warning for one panel.
Global states affect the whole app:
- Initial app session loading.
- Route-level data loading.
- App-wide offline state.
- Expired session.
- Maintenance or service outage.
- Unhandled route error.
- Unauthorized access to a protected area.
The key interview point: global UX should be reserved for states that truly affect navigation or the whole shell. Overusing global spinners and global toasts makes the app noisy and harder to use.
Loading State Types
Loading is not one state.
Common types:
- Initial app loading: checking session, bootstrapping config, loading critical shell data.
- Route loading: navigating to a route that requires data.
- Component loading: loading a specific panel or widget.
- Mutation pending: saving, deleting, submitting, uploading, or retrying.
- Background fetching: stale data is visible while fresh data loads.
- Blocking loading: user cannot continue until the request completes.
- Non-blocking loading: user can keep working while data refreshes.
Each type needs different UX. A full-page spinner may be acceptable for app bootstrap, but it is usually poor for background refresh.
Skeletons, Spinners, and Progress
Use loading indicators based on user context.
Skeletons are useful when:
- The layout is known.
- Content is loading for the first time.
- The user benefits from seeing page structure.
Spinners are useful when:
- The wait is short.
- The area is small.
- The layout is not predictable.
Progress bars are useful when:
- Progress is measurable.
- Upload or download size is known.
- A long operation has meaningful stages.
Avoid full-screen spinners for every request. They remove context and can make the app feel slower.
Example:
function UserPage() {
const { data, isPending, isFetching, error } = useUser();
if (isPending) {
return <UserPageSkeleton />;
}
if (error) {
return <InlineError message="Could not load user." />;
}
return (
<>
{isFetching ? <SmallStatus text="Refreshing..." /> : null}
<UserDetails user={data} />
</>
);
}
This preserves the loaded UI during background refresh.
Foreground Loading vs Background Fetching
Foreground loading means the app does not have the data needed to render the screen. Background fetching means the app already has data and is checking for updates.
Foreground loading UX:
- Skeleton.
- Route pending UI.
- Disabled submit button.
- Blocking modal for critical operation.
Background fetching UX:
- Subtle refresh indicator.
- "Updated just now" timestamp.
- Non-blocking spinner near the data region.
- Stale data remains visible.
The mistake is replacing existing data with a spinner during every refetch. That creates flicker and loses user context.
Error State Types
Different errors need different UX.
Common categories:
- Validation error: user can edit input.
- Authentication error: user needs to sign in again.
- Authorization error: user does not have permission.
- Not found error: resource no longer exists or URL is wrong.
- Conflict error: data changed or action is no longer valid.
- Rate limit error: user or client should wait.
- Network error: browser could not reach the server.
- Server error: backend failed.
- Unexpected app error: React rendering or route module failed.
Each category should map to a clear action: edit, retry, sign in, request access, go back, refresh, wait, or contact support.
Local Errors
Local errors should appear where the user can act.
Examples:
- Field validation error under the field.
- Save failure near the save button.
- Failed table row action on that row.
- Failed widget inside the widget card.
Example:
function SaveButton() {
const mutation = useSaveProfile();
return (
<div>
<button disabled={mutation.isPending} onClick={() => mutation.mutate()}>
{mutation.isPending ? "Saving..." : "Save"}
</button>
{mutation.isError ? (
<p role="alert">Could not save. Check your connection and try again.</p>
) : null}
</div>
);
}
Local errors keep the message close to the failed action.
Global Errors
Global errors are for failures that affect the whole app or route.
Examples:
- App cannot load configuration.
- The current route failed to load required data.
- The user's session expired.
- The whole API is unavailable.
- A React error boundary caught an unexpected rendering failure.
Global error UX should include:
- Clear title.
- Plain-language explanation.
- Recovery action.
- Optional support code or correlation ID.
- Navigation escape hatch.
Avoid generic "Something went wrong" screens with no action. That is not a recovery path; it is a shrug wearing a trench coat.
Route Error Boundaries
Route error boundaries are useful when a route-level loader, action, or component fails. They let the app show a scoped fallback instead of crashing the whole application.
Example shape:
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error) && error.status === 404) {
return <NotFoundPage />;
}
return (
<ErrorPage
title="We could not load this page"
actionLabel="Try again"
onAction={() => window.location.reload()}
/>
);
}
Good route boundaries distinguish:
404not found.401unauthenticated.403forbidden.- Unexpected server or rendering errors.
Unauthorized vs Forbidden
Unauthorized and forbidden are different UX states.
401 Unauthorized usually means:
- The user is not signed in.
- The session expired.
- The access token is invalid.
- Reauthentication or token refresh may fix it.
UX examples:
- Refresh token and retry original request.
- Show session expired message.
- Redirect to sign-in with return URL.
403 Forbidden usually means:
- The user is signed in but lacks permission.
- Refreshing the token usually will not help.
UX examples:
- Show "You do not have access."
- Offer request-access workflow.
- Link back to a safe page.
Treating every 403 as a sign-in problem creates loops and confuses users.
Session Expiration UX
When a session expires, the app should preserve user context when possible.
Good behavior:
- Stop retry loops.
- Clear sensitive cached data.
- Show a clear session-expired message.
- Redirect to sign-in with a safe return URL.
- Preserve unsaved non-sensitive form data when appropriate.
- Resume the original route after successful sign-in if allowed.
Example:
function handleUnauthorized() {
queryClient.clear();
authStore.clear();
navigate(`/login?returnTo=${encodeURIComponent(location.pathname)}`);
}
Do not silently redirect users away from unsaved work without warning if the product can avoid it.
Network Failure UX
Network failure is different from a server error. The browser may be offline, DNS may fail, a request may time out, or a device may switch networks.
Good network UX:
- Keep previously loaded data visible when safe.
- Show an offline or connection banner.
- Offer retry for foreground requests.
- Queue safe offline actions only if the product supports it.
- Avoid blaming the user with vague errors.
- Revalidate when connectivity returns.
Example:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const online = () => setIsOnline(true);
const offline = () => setIsOnline(false);
window.addEventListener("online", online);
window.addEventListener("offline", offline);
return () => {
window.removeEventListener("online", online);
window.removeEventListener("offline", offline);
};
}, []);
return isOnline;
}
Browser online/offline signals are useful hints, not perfect proof that the API is reachable.
Retry UX
Retries should match the operation.
Safe retry candidates:
- Idempotent reads.
- Search requests.
- Background refetch.
- Failed GET after network loss.
- Upload chunks designed for retry.
Risky retry candidates:
- Payments.
- Creating orders.
- Sending messages.
- Deleting data.
- Any mutation without idempotency support.
Good retry UX:
- Shows what failed.
- Explains whether retry is safe.
- Disables duplicate submissions while pending.
- Uses backoff for automatic retries.
- Stops after a reasonable limit.
- Preserves user input.
Automatic retry is not a substitute for a visible recovery action when the final attempt fails.
Global Loading State Aggregation
Some apps show a top progress bar or app-shell loading indicator when route navigation or critical requests are pending.
Example:
function GlobalProgress() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return isNavigating ? <div className="top-progress" /> : null;
}
For query libraries, aggregate fetching state carefully. A global spinner for every background refetch can make the app look permanently busy.
Better patterns:
- Top progress bar for navigation.
- Local skeletons for first load.
- Subtle "Refreshing" for background fetch.
- Button-level pending state for mutations.
Global Error Notifications
Global toasts are useful for cross-cutting events, but they are easy to overuse.
Good toast candidates:
- Background refresh failed while stale data remains visible.
- Save succeeded.
- Network connection lost.
- Session will expire soon.
- User lacks permission for an attempted action.
Poor toast candidates:
- Field validation errors.
- Every failed query on initial page load.
- Repeated identical network failures.
- Errors already shown inline.
A good global notification system should deduplicate repeated messages and avoid covering important controls.
Data Library State Mapping
TanStack Query and RTK Query expose request states that should map to UX deliberately.
Common mapping:
- First load pending: skeleton or route loading.
- Existing data plus fetching: subtle refresh indicator.
- Error with no data: inline or route error fallback.
- Error with stale data: keep stale data and show warning.
- Mutation pending: disable relevant action and show button state.
- Mutation error: show local error near the action.
- Unauthorized: trigger auth flow or session-expired handling.
Do not blindly turn every isFetching into a full-page spinner.
Accessibility
Loading and error UX should be accessible.
Practices:
- Use
aria-busyfor regions being updated. - Use
role="alert"for important errors. - Move focus to route-level errors when navigation fails.
- Keep button labels meaningful during pending state.
- Do not rely only on color.
- Preserve keyboard focus when retrying.
- Avoid spinner-only states with no text for long waits.
Example:
<section aria-busy={isFetching}>
{error ? <p role="alert">Could not load invoices.</p> : null}
<InvoiceTable invoices={invoices} />
</section>
Accessibility is not decoration here. It determines whether users can recover from failure.
Observability and Support
Global failures should be debuggable.
Useful context:
- Route.
- Request URL pattern.
- HTTP status.
- Error code.
- Correlation ID.
- User/session ID where allowed.
- Retry count.
- Network status.
- App version.
User-facing errors should avoid leaking internal details, but support teams need enough context to find the issue.
Example:
<ErrorPage
title="We could not load your dashboard"
description="Try again. If this keeps happening, contact support."
supportCode={correlationId}
/>
Common Mistakes
Common mistakes include:
- Showing a full-page spinner for every refetch.
- Clearing stale data during background refresh.
- Treating
401and403the same. - Showing global toasts for field validation errors.
- Retrying unsafe mutations automatically.
- Losing user input after a failed request.
- Hiding retry actions.
- Swallowing errors in an API client and leaving UI stuck.
- Showing technical stack traces to users.
- Forgetting accessibility for loading and errors.
- Failing to clear sensitive data after session expiration.
Best Practices
Best practices include:
- Classify loading as initial, route, component, mutation, or background.
- Keep stale data visible during background refresh when safe.
- Show local errors near local actions.
- Use route error boundaries for route failures.
- Separate unauthenticated from forbidden states.
- Preserve user input on failed submissions.
- Retry only safe operations automatically.
- Provide visible retry actions after final failure.
- Use offline banners for network-wide failure.
- Deduplicate global notifications.
- Clear sensitive cache on logout or definitive auth failure.
- Log enough diagnostic context without exposing secrets.