Mastering Loading States in Next.js: Effective Use of Suspense and loading.tsx

Rishi BakshiRishi Bakshi
5 min read

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, the fallback 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.

12
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.