Mastering Loading States in Next.js: Effective Use of Suspense and loading.tsx
When dealing with server components and fetching data in Next.js, a common mistake is neglecting to handle the loading state effectively. Imagine you're loading product data in a server component, and while the API request is being processed, the page can take several seconds to respond, leaving users confused or frustrated by a seemingly "stuck" UI. To avoid this, Next.js provides a built-in mechanism: the loading.tsx
file, along with React’s Suspense
component for streaming and suspenseful rendering.
Let’s walk through the proper way to implement loading states and avoid common pitfalls.
The Problem: UI Freezing During Data Fetching
When your server-side component fetches data (e.g., a list of products), the delay in rendering due to the API call can cause the page to feel unresponsive. Without handling loading states correctly, users won’t know if the app is functioning, as the entire page might seem to "freeze" until the server sends back the rendered content.
To address this, Next.js allows you to create a loading.tsx
file to display a loading indicator while your server-side content is being fetched. But even with this, some developers fall into a trap where the entire page becomes blank, showing only the loading indicator, instead of progressively rendering certain parts of the UI (e.g., the header and footer).
The loading.tsx
File in Next.js
The loading.tsx
file is used to display a loading state while the server-side data is being fetched. This file is automatically triggered when a page is loading its content, but if you're not careful, you may end up blocking the entire page from rendering (including components that don’t depend on the fetch).
Let’s implement a simple loading state:
// app/products/loading.tsx
const Loading = () => {
return <div>Loading Products...</div>;
};
export default Loading;
In this case, while the data for the product page is being fetched, Loading Products...
will be displayed. However, this approach wraps the entire page, meaning the header, footer, and other static parts of the page are hidden too, which is not ideal.
The Solution: Granular Suspense for Better User Experience
The key to improving the loading experience is to isolate the loading state to the part of the page that requires the data fetch. For example, the header and footer should render immediately, while only the product list should display a loading state.
To achieve this, we can extract the product fetching logic into a ProductList component and wrap it in Suspense
inside the page component.
Example: Page Component with Suspense
// app/products/page.tsx
import { Suspense } from "react";
import ProductList from "./ProductList";
const ProductsPage = () => {
return (
<div>
<header>Product Store</header>
{/* Suspense only wraps the ProductList component */}
<Suspense fallback={<div>Loading Products...</div>}>
<ProductList />
</Suspense>
<footer>© 2024 Product Store</footer>
</div>
);
};
export default ProductsPage;
In this setup:
The header and footer will display immediately, providing users with instant feedback.
The ProductList component, which handles data fetching, is wrapped inside
Suspense
. If the data takes time to load, thefallback
content (Loading Products...
) will be shown only in the section where products are rendered.
Example: ProductList Component
// app/products/ProductList.tsx
async function fetchProducts() {
const res = await fetch("/api/products");
const products = await res.json();
return products;
}
const ProductList = async () => {
const products = await fetchProducts();
return (
<div>
{products.map(product => (
<div key={product.id}>
<h2>{product.name}</h2>
<p>{product.description}</p>
</div>
))}
</div>
);
};
export default ProductList;
Here, ProductList
performs the fetch operation, and the suspense mechanism ensures that only the product list area shows a loading state.
Common Pitfall: Wrapping Suspense Too Low
One common mistake is trying to encapsulate the Suspense
logic within the component that fetches the data itself (i.e., inside ProductList
). This approach doesn't work because the Suspense
component needs to be higher up in the component hierarchy to suspend rendering during data fetching.
If you place the Suspense
inside the data-fetching component, the loading fallback won’t be shown properly, and you may experience the same unresponsive UI as before.
Another Pitfall: Forgetting the key
Prop for Suspense
Another important aspect of using Suspense
is handling URL changes when working with search parameters. For instance, if you’re navigating between products using links like:
/products?id=2
/products?id=3
/products?id=4
Without specifying a key
prop in the Suspense
component, changing the product ID via the URL will not trigger the loading state again, leading to laggy transitions between products.
Correct Use of key
in Suspense
// app/products/page.tsx
import { Suspense } from "react";
import ProductList from "./ProductList";
const ProductsPage = ({ searchParams }: { searchParams: { id: string } }) => {
return (
<div>
<header>Product Store</header>
{/* Add key to Suspense so it reloads on URL changes */}
<Suspense fallback={<div>Loading Product...</div>} key={searchParams.id}>
<ProductList productId={searchParams.id} />
</Suspense>
<footer>© 2024 Product Store</footer>
</div>
);
};
export default ProductsPage;
In this example, we assign the search parameter id
as the key
for the Suspense
component. This ensures that whenever the product ID changes in the URL (for example, from id=2
to id=3
), the component is re-rendered with the appropriate loading fallback.
Conclusion
Handling loading states effectively in Next.js involves leveraging the loading.tsx
file and React’s Suspense
component for granular control over what gets displayed while data is being fetched. By isolating your fetch logic into separate components and wrapping only the necessary parts of your UI in Suspense
, you can provide a smoother, more responsive user experience.
Avoid common pitfalls such as wrapping Suspense
too low in the component tree or forgetting to add a key
to Suspense
when working with dynamic routes and search parameters. These small but significant improvements will ensure that your Next.js application feels fast and responsive, even when dealing with slow API calls.
Subscribe to my newsletter
Read articles from Rishi Bakshi directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Rishi Bakshi
Rishi Bakshi
Full Stack Developer with experience in building end-to-end encrypted chat services. Currently dedicated in improving my DSA skills to become a better problem solver and deliver more efficient, scalable solutions.