Centralizing Loading States in React with Suspense and React Query
Scattered loading spinners clutter the UI and add boilerplate. React Suspense declares a single loading boundary; with React Query you can drop per‑component flags. This guide covers the problem, solution, example, trade‑offs, and a checklist.
03 Dec 2025, 05:42 UTC

Problem: Scattered Loading States
In a typical React app, each component that fetches data manages its own isLoading flag and renders a spinner or placeholder. When you have several nested components, the UI ends up with dozens of small loading indicators. The result is a noisy interface, duplicated logic, and a hard‑to‑maintain codebase.
Moreover, developers often write boilerplate around every hook: if (loading) return ;. This pattern is easy to forget, and the UI can end up with inconsistent loading styles or missing spinners when a component is refactored.
Thesis: Suspense Turns Asynchronous Into Declarative
React Suspense was introduced in React 18 as a way to suspend rendering until an async resource is ready. Instead of sprinkling loading flags throughout your tree, you wrap a subtree in <Suspense fallback=<Spinner />>. When any child component throws a promise (the usual pattern for async data), React pauses rendering of that subtree and displays the fallback UI until the promise resolves. Once resolved, the subtree resumes rendering with the fetched data.
The key benefits are:
- Single source of truth for loading UI.
- Automatic propagation of loading state to deeply nested components.
- Declarative UI that mirrors data readiness.
Hooking Up React Query for Suspense
React Query already supports Suspense. By setting suspense: true in the useQuery options, the hook throws a promise while the query is pending. A parent Suspense boundary can then catch that promise and show a fallback.
Example: A Simple Product List
// src/components/ProductList.jsx
import { useQuery } from "react-query";
import { Spinner } from "./Spinner";
function fetchProducts() {
return fetch("/api/products").then(res => res.json());
}
export function ProductList() {
const { data } = useQuery("products", fetchProducts, {
suspense: true, // Enable Suspense support
});
return (
{data.map(p => (
- {p.name}
))}
);
}
// src/App.jsx
import { Suspense } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { ProductList } from "./components/ProductList";
import { Spinner } from "./components/Spinner";
const queryClient = new QueryClient();
export default function App() {
return (
}> {/* One global loading UI */}
);
}
Run this in a React 18 environment. While fetchProducts resolves, the <Spinner /> is shown. Once the promise resolves, the ProductList renders with data.
Adding Error Handling
Suspense only handles loading. Errors must be caught by an ErrorBoundary. A minimal implementation:
// src/components/ErrorBoundary.jsx
import { Component } from "react";
export class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
Wrap the Suspense tree in this boundary to catch any rejected promises:
// src/App.jsx
import { ErrorBoundary } from "./components/ErrorBoundary";
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<ProductList />
</Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
}
Trade‑offs & Limitations
| Aspect | Consideration | Mitigation |
|---|---|---|
| Library Support | Only libraries that throw promises (React Query, Relay, TanStack Query) work out of the box. | For custom hooks, wrap the async call in a createResource helper or use useSWR with suspense:true if available. |
| Server‑Side Rendering (SSR) | Fallback UI is not rendered on the server; hydration mismatch can occur if the client shows a spinner immediately. | Use ReactDOM.hydrateRoot with isomorphic‑react‑suspense or Next.js’s app router with useRouter().isFallback handling. |
| Concurrent Mode Complexity | Suspense is tied to concurrent rendering; enabling it may affect other parts of the app. | Test thoroughly; use ReactDOM.createRoot and enableExperimentalConcurrentFeatures only in production when ready. |
| Granularity of Loading UI | One fallback per boundary means you lose fine‑grained control over where spinners appear. | Define multiple nested Suspense boundaries for critical sections that need distinct loaders. |
Actionable Checklist
- Ensure your React version is 18 or newer and that
ReactDOM.createRootis used. - Wrap your root component in
<QueryClientProvider>and enablesuspense:trueon all data hooks. - Place a top‑level
<Suspense fallback=<Spinner />>around the subtree that needs loading handling. - Add an
ErrorBoundarythat surrounds theSuspensetree to catch promise rejections. - For SSR, confirm that fallback UI is not rendered on the server by inspecting the HTML output or using a hydration test.
- Measure perceived performance: use Chrome DevTools’
Performancepanel to verify that the fallback disappears exactly when data is ready. - Document the pattern in your style guide so new contributors know to use
suspense:trueand avoid manual loading flags.
By adopting Suspense with a library like React Query, you shift from ad‑hoc loading flags to a declarative boundary that automatically handles state, reduces boilerplate, and creates a consistent user experience. The trade‑offs are manageable with careful SSR configuration and a clear boundary strategy, making Suspense a powerful tool for modern React applications.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.