We use cookies to enhance your experience on the site
CodeWorlds

Loading Skeletons and Progress Indicators

In the previous module, we discussed how to use React Suspense and data streaming to improve loading performance and user interface responsiveness. However, simply waiting for data to load without properly informing the user about what's happening can cause frustration and the impression that the application is not working. In this module, we will focus on creating advanced loading skeletons and progress indicators that complement the data streaming strategy and ensure an excellent user experience.

Why Are Loading Skeletons Important?

Loading skeletons and progress indicators are key elements of modern web applications for several reasons:

  1. They reduce perceived latency - even if the actual loading time doesn't change, users perceive it as shorter when they see something is happening
  2. They prevent layout shifts (CLS) - they maintain the page structure, reducing shifts during loading
  3. They communicate application state - they inform the user that data is loading, instead of leaving them uncertain
  4. They improve Core Web Vitals metrics - lower CLS and better LCP (Largest Contentful Paint) translate to better performance scores

Types of Loading State Indicators

There are several approaches to informing the user about the loading state:

  1. Loading skeletons (Skeleton UI) - simplified graphical representations of the content that will be displayed
  2. Progress indicators - visual representations of loading progress (bars, circles)
  3. Indeterminate loaders - animated indicators (e.g., spinning circles) that don't show exact progress
  4. Delayed rendering - showing loading indicators only after a specified time to avoid flickering
  5. Transition animations - smooth transitions between loading states and ready content

Implementing Loading Skeletons in Next.js

1. Basic Skeleton Components

Let's start by creating basic skeleton components that we can use as placeholders during loading:

1// components/ui/skeleton.tsx
2import { cn } from '@/lib/utils';
3
4interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {}
5
6export function Skeleton({ className, ...props }: SkeletonProps) {
7  return (
8    <div
9      className={cn(
10        "animate-pulse rounded-md bg-gray-200 dark:bg-gray-800",
11        className
12      )}
13      {...props}
14    />
15  );
16}

This simple component creates a pulsing element that can be customized with classes and properties. We can now use it to create different types of loading skeletons.

2. Advanced Skeletons for Specific Content Types

We can create more advanced skeletons for specific content types, such as product cards, tables, or lists:

1// components/skeletons/product-card-skeleton.tsx
2import { Skeleton } from "@/components/ui/skeleton";
3
4export function ProductCardSkeleton() {
5  return (
6    <div className="space-y-4 rounded-lg border p-4">
7      {/* Product image skeleton */}
8      <Skeleton className="h-[200px] w-full rounded-lg" />
9
10      {/* Product name skeleton */}
11      <Skeleton className="h-8 w-3/4" />
12
13      {/* Price and rating skeleton */}
14      <div className="flex justify-between">
15        <Skeleton className="h-6 w-1/4" />
16        <Skeleton className="h-6 w-1/4" />
17      </div>
18
19      {/* Button skeleton */}
20      <Skeleton className="h-10 w-full" />
21    </div>
22  );
23}
1// components/skeletons/table-skeleton.tsx
2import { Skeleton } from "@/components/ui/skeleton";
3
4interface TableSkeletonProps {
5  rows?: number;
6  columns?: number;
7}
8
9export function TableSkeleton({ rows = 5, columns = 4 }: TableSkeletonProps) {
10  return (
11    <div className="w-full space-y-3 rounded-lg border p-4">
12      {/* Table header */}
13      <div className="flex gap-3 pb-2">
14        {Array.from({ length: columns }).map((_, i) => (
15          <Skeleton key={`header-${i}`} className="h-10 flex-1" />
16        ))}
17      </div>
18
19      {/* Table rows */}
20      {Array.from({ length: rows }).map((_, rowIndex) => (
21        <div key={`row-${rowIndex}`} className="flex gap-3">
22          {Array.from({ length: columns }).map((_, colIndex) => (
23            <Skeleton
24              key={`cell-${rowIndex}-${colIndex}`}
25              className="h-12 flex-1"
26            />
27          ))}
28        </div>
29      ))}
30    </div>
31  );
32}

3. Using Skeletons with React Suspense

Now we can combine our loading skeletons with React Suspense to use them as fallbacks during data streaming:

1// app/products/page.tsx
2import { Suspense } from "react";
3import { ProductGrid } from "./product-grid";
4import { ProductFilters } from "./product-filters";
5import { ProductGridSkeleton } from "@/components/skeletons/product-grid-skeleton";
6import { ProductFiltersSkeleton } from "@/components/skeletons/product-filters-skeleton";
7
8export default function ProductsPage() {
9  return (
10    <div className="container mx-auto py-10">
11      <h1 className="mb-8 text-3xl font-bold">Our Products</h1>
12
13      <div className="grid grid-cols-4 gap-6">
14        <div className="col-span-1">
15          <Suspense fallback={<ProductFiltersSkeleton />}>
16            <ProductFilters />
17          </Suspense>
18        </div>
19
20        <div className="col-span-3">
21          <Suspense fallback={<ProductGridSkeleton />}>
22            <ProductGrid />
23          </Suspense>
24        </div>
25      </div>
26    </div>
27  );
28}

In this example, we create skeletons for the product filters and product grid, so during loading the user sees the appropriate page structure.

Progress Indicators in Next.js

In addition to loading skeletons, we can also use progress indicators to inform the user about the state of operations, especially those that take more time.

1. Simple Progress Indicator

1// components/ui/progress.tsx
2"use client";
3
4import * as React from "react";
5import * as ProgressPrimitive from "@radix-ui/react-progress";
6import { cn } from "@/lib/utils";
7
8interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
9  indicatorClassName?: string;
10}
11
12const Progress = React.forwardRef<
13  React.ElementRef<typeof ProgressPrimitive.Root>,
14  ProgressProps
15>(({ className, value, indicatorClassName, ...props }, ref) => (
16  <ProgressPrimitive.Root
17    ref={ref}
18    className={cn(
19      "relative h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800",
20      className
21    )}
22    {...props}
23  >
24    <ProgressPrimitive.Indicator
25      className={cn(
26        "h-full w-full flex-1 bg-blue-600 transition-all dark:bg-blue-400",
27        indicatorClassName
28      )}
29      style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
30    />
31  </ProgressPrimitive.Root>
32));
33Progress.displayName = ProgressPrimitive.Root.displayName;
34
35export { Progress };

2. Indeterminate Loading Indicator (Spinner)

1// components/ui/spinner.tsx
2import { cn } from "@/lib/utils";
3
4interface SpinnerProps extends React.HTMLAttributes<HTMLDivElement> {
5  size?: "sm" | "md" | "lg";
6  variant?: "primary" | "secondary";
7}
8
9export function Spinner({
10  className,
11  size = "md",
12  variant = "primary",
13  ...props
14}: SpinnerProps) {
15  const sizeClasses = {
16    sm: "h-4 w-4 border-2",
17    md: "h-6 w-6 border-2",
18    lg: "h-10 w-10 border-3",
19  };
20
21  const variantClasses = {
22    primary: "border-blue-600 border-t-transparent",
23    secondary: "border-gray-600 border-t-transparent",
24  };
25
26  return (
27    <div
28      className={cn(
29        "inline-block animate-spin rounded-full",
30        sizeClasses[size],
31        variantClasses[variant],
32        className
33      )}
34      role="status"
35      aria-label="loading"
36      {...props}
37    >
38      <span className="sr-only">Loading...</span>
39    </div>
40  );
41}

Application-wide Progress Indicators

In Next.js, we can also implement application-wide progress indicators that inform the user about the navigation state between pages.

1. Global Progress Indicator for Page Transitions

1// components/global-progress.tsx
2"use client";
3
4import { useEffect, useState } from "react";
5import { usePathname, useSearchParams } from "next/navigation";
6import NProgress from "nprogress";
7import "nprogress/nprogress.css";
8
9export function GlobalProgress() {
10  const pathname = usePathname();
11  const searchParams = useSearchParams();
12  const [isNavigating, setIsNavigating] = useState(false);
13
14  useEffect(() => {
15    // NProgress configuration
16    NProgress.configure({ showSpinner: false });
17
18    // Handling navigation start
19    function onRouteChangeStart() {
20      setIsNavigating(true);
21      NProgress.start();
22    }
23
24    // Handling navigation end
25    function onRouteChangeComplete() {
26      setIsNavigating(false);
27      NProgress.done();
28    }
29
30    // Adding event listeners
31    window.addEventListener("beforeunload", onRouteChangeStart);
32
33    // Removing event listeners on component unmount
34    return () => {
35      window.removeEventListener("beforeunload", onRouteChangeStart);
36    };
37  }, []);
38
39  // Reacting to path or search parameter changes
40  useEffect(() => {
41    if (isNavigating) {
42      NProgress.done();
43      setIsNavigating(false);
44    }
45  }, [pathname, searchParams, isNavigating]);
46
47  return null; // This component renders nothing, it only manages NProgress
48}

Then we can add this component to our layout.tsx:

1// app/layout.tsx
2import { GlobalProgress } from "@/components/global-progress";
3
4export default function RootLayout({ children }) {
5  return (
6    <html lang="en">
7      <body>
8        <GlobalProgress />
9        {children}
10      </body>
11    </html>
12  );
13}

Advanced Loading State Management Techniques

1. Delayed Loading Indicator Rendering

In some cases, data loading is so fast that showing a loading indicator causes unnecessary interface flickering. We can solve this problem by delaying the loading indicator rendering:

1// hooks/use-delayed-loading.tsx
2import { useState, useEffect } from "react";
3
4export function useDelayedLoading(loading: boolean, delay = 300) {
5  const [showLoading, setShowLoading] = useState(false);
6
7  useEffect(() => {
8    if (loading) {
9      const timer = setTimeout(() => {
10        setShowLoading(true);
11      }, delay);
12
13      return () => clearTimeout(timer);
14    } else {
15      setShowLoading(false);
16    }
17  }, [loading, delay]);
18
19  return showLoading && loading;
20}

Now we can use this hook in our components:

1// components/delayed-loading.tsx
2"use client";
3
4import { useDelayedLoading } from "@/hooks/use-delayed-loading";
5
6interface DelayedLoadingProps {
7  loading: boolean;
8  delay?: number;
9  fallback: React.ReactNode;
10  children: React.ReactNode;
11}
12
13export function DelayedLoading({
14  loading,
15  delay = 300,
16  fallback,
17  children
18}: DelayedLoadingProps) {
19  const showLoading = useDelayedLoading(loading, delay);
20
21  return showLoading ? fallback : children;
22}

2. Custom Suspense with Delay

We can also create a custom Suspense-based component that introduces a delay:

1// components/delayed-suspense.tsx
2"use client";
3
4import { Suspense, useState, useEffect } from "react";
5
6interface DelayedSuspenseProps {
7  fallback: React.ReactNode;
8  children: React.ReactNode;
9  delay?: number;
10}
11
12export function DelayedSuspense({
13  fallback,
14  children,
15  delay = 300
16}: DelayedSuspenseProps) {
17  const [showFallback, setShowFallback] = useState(false);
18
19  useEffect(() => {
20    const timer = setTimeout(() => {
21      setShowFallback(true);
22    }, delay);
23
24    return () => clearTimeout(timer);
25  }, [delay]);
26
27  return (
28    <Suspense fallback={showFallback ? fallback : null}>
29      {children}
30    </Suspense>
31  );
32}

Managing Cumulative Layout Shift (CLS)

Layout shifts (CLS) are one of the key Core Web Vitals metrics. Well-designed loading skeletons can help minimize layout shifts by maintaining the dimensions and proportions of elements.

1. Preserving Dimensions

1// components/skeletons/image-skeleton.tsx
2import { Skeleton } from "@/components/ui/skeleton";
3
4interface ImageSkeletonProps {
5  width: number;
6  height: number;
7  aspectRatio?: number;
8}
9
10export function ImageSkeleton({ width, height, aspectRatio }: ImageSkeletonProps) {
11  return (
12    <div
13      className="overflow-hidden rounded-lg"
14      style={{
15        width: width ? `${width}px` : '100%',
16        height: height ? `${height}px` : '100%',
17        aspectRatio: aspectRatio ? `${aspectRatio}` : undefined,
18      }}
19    >
20      <Skeleton className="h-full w-full" />
21    </div>
22  );
23}

2. Loading Images with Space Preservation

1// components/ui/image.tsx
2"use client";
3
4import { useState } from "react";
5import Image, { ImageProps } from "next/image";
6import { Skeleton } from "@/components/ui/skeleton";
7
8interface OptimizedImageProps extends Omit<ImageProps, "onLoad" | "onError"> {
9  fallbackClassName?: string;
10}
11
12export function OptimizedImage({
13  fallbackClassName,
14  className,
15  alt,
16  ...props
17}: OptimizedImageProps) {
18  const [isLoading, setIsLoading] = useState(true);
19
20  return (
21    <div className="relative">
22      {isLoading && (
23        <Skeleton
24          className={fallbackClassName || className}
25          style={{
26            position: "absolute",
27            top: 0,
28            left: 0,
29            right: 0,
30            bottom: 0,
31          }}
32        />
33      )}
34      <Image
35        className={className}
36        alt={alt}
37        onLoad={() => setIsLoading(false)}
38        {...props}
39      />
40    </div>
41  );
42}

Form Submission State Indicators

In addition to loading indicators for incoming data, it's also worth showing the state of data submission operations.

1. Button with Loading Indicator

1// components/ui/loading-button.tsx
2import { ButtonHTMLAttributes } from "react";
3import { Button } from "@/components/ui/button";
4import { Spinner } from "@/components/ui/spinner";
5import { cn } from "@/lib/utils";
6
7interface LoadingButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
8  loading?: boolean;
9  variant?: "default" | "outline" | "ghost" | "link";
10  size?: "default" | "sm" | "lg";
11  spinnerClassName?: string;
12}
13
14export function LoadingButton({
15  children,
16  loading,
17  className,
18  spinnerClassName,
19  disabled,
20  ...props
21}: LoadingButtonProps) {
22  return (
23    <Button
24      className={cn("relative", className)}
25      disabled={disabled || loading}
26      {...props}
27    >
28      {loading && (
29        <span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
30          <Spinner size="sm" className={spinnerClassName} />
31        </span>
32      )}
33      <span className={cn(loading ? "invisible" : "")}>{children}</span>
34    </Button>
35  );
36}

2. Form with State Indicator

1// components/submit-form.tsx
2"use client";
3
4import { useState } from "react";
5import { useRouter } from "next/navigation";
6import { LoadingButton } from "@/components/ui/loading-button";
7
8export function ContactForm() {
9  const [loading, setLoading] = useState(false);
10  const [success, setSuccess] = useState(false);
11  const [error, setError] = useState<string | null>(null);
12  const router = useRouter();
13
14  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
15    event.preventDefault();
16    setLoading(true);
17    setError(null);
18
19    try {
20      const formData = new FormData(event.currentTarget);
21      const response = await fetch('/api/contact', {
22        method: 'POST',
23        body: formData,
24      });
25
26      if (!response.ok) {
27        throw new Error('An error occurred while sending the message');
28      }
29
30      setSuccess(true);
31      event.currentTarget.reset();
32
33      // Redirect after successful submission
34      setTimeout(() => {
35        router.push('/contact/thank-you');
36      }, 1500);
37    } catch (err) {
38      setError(err instanceof Error ? err.message : 'An unknown error occurred');
39    } finally {
40      setLoading(false);
41    }
42  }
43
44  return (
45    <form onSubmit={handleSubmit} className="space-y-6">
46      {/* Form fields */}
47      <div className="space-y-4">
48        <input
49          type="text"
50          name="name"
51          required
52          className="w-full rounded-md border p-3"
53          disabled={loading}
54        />
55        <input
56          type="email"
57          name="email"
58          required
59          className="w-full rounded-md border p-3"
60          disabled={loading}
61        />
62        <textarea
63          name="message"
64          required
65          className="h-32 w-full rounded-md border p-3"
66          disabled={loading}
67        />
68      </div>
69
70      {/* Form state */}
71      {error && (
72        <div className="rounded-md bg-red-50 p-4 text-red-600">
73          {error}
74        </div>
75      )}
76
77      {success && (
78        <div className="rounded-md bg-green-50 p-4 text-green-600">
79          Message sent successfully!
80        </div>
81      )}
82
83      {/* Submit button */}
84      <LoadingButton
85        type="submit"
86        loading={loading}
87        className="w-full"
88      >
89        Send message
90      </LoadingButton>
91    </form>
92  );
93}

Progress Indicators for Long-running Operations

For operations that take longer, it's worth showing progress indicators that inform the user about the current state and remaining time.

1. Percentage Progress Indicator

1// components/ui/percent-progress.tsx
2"use client";
3
4import { Progress } from "@/components/ui/progress";
5
6interface PercentProgressProps {
7  value: number;
8  max: number;
9  label?: string;
10  showPercent?: boolean;
11  className?: string;
12}
13
14export function PercentProgress({
15  value,
16  max,
17  label,
18  showPercent = true,
19  className
20}: PercentProgressProps) {
21  const percent = Math.min(Math.round((value / max) * 100), 100);
22
23  return (
24    <div className={className}>
25      {label && (
26        <div className="mb-2 flex justify-between">
27          <span>{label}</span>
28          {showPercent && <span>{percent}%</span>}
29        </div>
30      )}
31      <Progress value={percent} />
32    </div>
33  );
34}

2. File Upload Progress Indicator

1// components/file-upload-progress.tsx
2"use client";
3
4import { useState } from "react";
5import { PercentProgress } from "@/components/ui/percent-progress";
6
7interface FileUploadProgressProps {
8  onUploadComplete: (url: string) => void;
9  accept?: string;
10  maxSizeMB?: number;
11}
12
13export function FileUploadProgress({
14  onUploadComplete,
15  accept = "image/*",
16  maxSizeMB = 5
17}: FileUploadProgressProps) {
18  const [progress, setProgress] = useState(0);
19  const [uploading, setUploading] = useState(false);
20  const [error, setError] = useState<string | null>(null);
21
22  async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
23    const file = event.target.files?.[0];
24    if (!file) return;
25
26    // Check file size
27    if (file.size > maxSizeMB * 1024 * 1024) {
28      setError(`The file is too large. Maximum size is ${maxSizeMB}MB`);
29      return;
30    }
31
32    setUploading(true);
33    setError(null);
34    setProgress(0);
35
36    try {
37      const formData = new FormData();
38      formData.append('file', file);
39
40      const xhr = new XMLHttpRequest();
41
42      // Listening to upload progress
43      xhr.upload.addEventListener('progress', (event) => {
44        if (event.lengthComputable) {
45          const percentComplete = (event.loaded / event.total) * 100;
46          setProgress(percentComplete);
47        }
48      });
49
50      // Handling upload completion
51      xhr.addEventListener('load', () => {
52        if (xhr.status === 200) {
53          const response = JSON.parse(xhr.responseText);
54          onUploadComplete(response.url);
55        } else {
56          setError('An error occurred while uploading the file');
57        }
58        setUploading(false);
59      });
60
61      // Handling errors
62      xhr.addEventListener('error', () => {
63        setError('A network error occurred while uploading the file');
64        setUploading(false);
65      });
66
67      // Sending the file
68      xhr.open('POST', '/api/upload');
69      xhr.send(formData);
70    } catch (err) {
71      setError('An unexpected error occurred');
72      setUploading(false);
73    }
74  }
75
76  return (
77    <div className="space-y-4">
78      <label className="block">
79        <span className="mb-1 block font-medium">Choose a file:</span>
80        <input
81          type="file"
82          accept={accept}
83          onChange={handleUpload}
84          disabled={uploading}
85          className="w-full cursor-pointer rounded-md border p-2 file:mr-4 file:rounded file:border-0 file:bg-blue-50 file:px-4 file:py-2 file:text-sm file:font-medium file:text-blue-600 hover:file:bg-blue-100"
86        />
87      </label>
88
89      {uploading && (
90        <PercentProgress
91          value={progress}
92          max={100}
93          label="Uploading file"
94          showPercent
95        />
96      )}
97
98      {error && (
99        <div className="rounded-md bg-red-50 p-3 text-sm text-red-600">
100          {error}
101        </div>
102      )}
103    </div>
104  );
105}

Integration with React Suspense and Server Components

In Next.js, we can combine our loading skeletons and progress indicators with React Suspense and Server Components to create a smooth user experience.

1. Dynamic Product Page with Different Loading Indicators

1// app/products/page.tsx
2import { Suspense } from "react";
3import { ProductHeader } from "./product-header";
4import { ProductGrid } from "./product-grid";
5import { ProductFilters } from "./product-filters";
6import { ProductSearch } from "./product-search";
7import { ProductGridSkeleton } from "@/components/skeletons/product-grid-skeleton";
8import { ProductFiltersSkeleton } from "@/components/skeletons/product-filters-skeleton";
9import { ProductHeaderSkeleton } from "@/components/skeletons/product-header-skeleton";
10import { DelayedSuspense } from "@/components/delayed-suspense";
11
12export default async function ProductsPage({
13  searchParams
14}: {
15  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
16}) {
17  return (
18    <div className="container mx-auto py-10">
19      {/* Priority 1: Page header - low latency */}
20      <Suspense fallback={<ProductHeaderSkeleton />}>
21        <ProductHeader />
22      </Suspense>
23
24      {/* Priority 2: Search bar - small delay */}
25      <div className="mb-6">
26        <ProductSearch initialQuery={(await searchParams).q as string} />
27      </div>
28
29      <div className="grid grid-cols-4 gap-6">
30        {/* Priority 3: Filters - delayed rendering */}
31        <div className="col-span-1">
32          <DelayedSuspense
33            fallback={<ProductFiltersSkeleton />}
34            delay={300}
35          >
36            <ProductFilters
37              initialCategory={(await searchParams).category as string}
38              initialPriceRange={{
39                min: (await searchParams).minPrice as string,
40                max: (await searchParams).maxPrice as string,
41              }}
42            />
43          </DelayedSuspense>
44        </div>
45
46        {/* Priority 4: Product grid - with full skeleton */}
47        <div className="col-span-3">
48          <Suspense fallback={<ProductGridSkeleton items={12} />}>
49            <ProductGrid
50              category={(await searchParams).category as string}
51              query={(await searchParams).q as string}
52              sort={(await searchParams).sort as string}
53              minPrice={(await searchParams).minPrice as string}
54              maxPrice={(await searchParams).maxPrice as string}
55            />
56          </Suspense>
57        </div>
58      </div>
59    </div>
60  );
61}

2. Product Component Implementation

1// app/products/product-grid.tsx
2import { ProductCard } from "@/components/product-card";
3
4interface ProductGridProps {
5  category?: string;
6  query?: string;
7  sort?: string;
8  minPrice?: string;
9  maxPrice?: string;
10}
11
12async function getProducts(params: ProductGridProps) {
13  // Parameter conversion
14  const minPrice = (await params).minPrice ? parseInt((await params).minPrice) : undefined;
15  const maxPrice = (await params).maxPrice ? parseInt((await params).maxPrice) : undefined;
16
17  // Simulating network delay for skeleton loading demonstration
18  await new Promise(resolve => setTimeout(resolve, 1500));
19
20  // A real implementation would fetch data from an API or database
21  const response = await fetch('https://api.example.com/products', {
22    method: 'POST',
23    headers: { 'Content-Type': 'application/json' },
24    body: JSON.stringify({
25      category: (await params).category,
26      query: (await params).query,
27      sort: (await params).sort,
28      filters: { price: { min: minPrice, max: maxPrice } }
29    }),
30    // In a real implementation, you might use cache, revalidate, etc.
31    cache: 'no-store'
32  });
33
34  if (!response.ok) {
35    throw new Error('Problem fetching products');
36  }
37
38  return response.json();
39}
40
41export async function ProductGrid(props: ProductGridProps) {
42  const products = await getProducts(props);
43
44  if (products.length === 0) {
45    return (
46      <div className="py-10 text-center">
47        <h3 className="text-xl font-medium">No products found</h3>
48        <p className="mt-2 text-gray-500">
49          Try changing your search criteria or filters
50        </p>
51      </div>
52    );
53  }
54
55  return (
56    <div className="grid grid-cols-3 gap-6">
57      {products.map(product => (
58        <ProductCard key={product.id} product={product} />
59      ))}
60    </div>
61  );
62}

Loading State Indicators in Client Components

In client components, we use different methods to display loading state indicators because we cannot use React Suspense for asynchronous data fetching in these components.

1. useAsyncData Hook for Loading Indicators

1// hooks/use-async-data.ts
2import { useState, useEffect } from "react";
3
4type AsyncStatus = "idle" | "loading" | "success" | "error";
5
6interface UseAsyncDataResult<T> {
7  data: T | null;
8  status: AsyncStatus;
9  error: Error | null;
10  isLoading: boolean;
11  isSuccess: boolean;
12  isError: boolean;
13  reload: () => void;
14}
15
16export function useAsyncData<T>(
17  fetchFn: () => Promise<T>,
18  dependencies: any[] = []
19): UseAsyncDataResult<T> {
20  const [data, setData] = useState<T | null>(null);
21  const [status, setStatus] = useState<AsyncStatus>("idle");
22  const [error, setError] = useState<Error | null>(null);
23  const [reloadCounter, setReloadCounter] = useState(0);
24
25  function reload() {
26    setReloadCounter(prev => prev + 1);
27  }
28
29  useEffect(() => {
30    let isMounted = true;
31
32    async function loadData() {
33      setStatus("loading");
34      setError(null);
35
36      try {
37        const result = await fetchFn();
38
39        if (isMounted) {
40          setData(result);
41          setStatus("success");
42        }
43      } catch (e) {
44        if (isMounted) {
45          setError(e instanceof Error ? e : new Error(String(e)));
46          setStatus("error");
47        }
48      }
49    }
50
51    loadData();
52
53    return () => {
54      isMounted = false;
55    };
56  }, [...dependencies, reloadCounter]);
57
58  return {
59    data,
60    status,
61    error,
62    isLoading: status === "loading",
63    isSuccess: status === "success",
64    isError: status === "error",
65    reload,
66  };
67}

2. Using the Hook in a Client Component

1// components/user-dashboard.tsx
2"use client";
3
4import { useAsyncData } from "@/hooks/use-async-data";
5import { Spinner } from "@/components/ui/spinner";
6import { ProductCardSkeleton } from "@/components/skeletons/product-card-skeleton";
7
8interface UserDashboardProps {
9  userId: string;
10}
11
12async function fetchUserDashboardData(userId: string) {
13  const response = await fetch(`/api/users/${userId}/dashboard`);
14  if (!response.ok) {
15    throw new Error('Problem fetching dashboard data');
16  }
17  return response.json();
18}
19
20export function UserDashboard({ userId }: UserDashboardProps) {
21  const {
22    data,
23    isLoading,
24    isError,
25    error,
26    reload
27  } = useAsyncData(() => fetchUserDashboardData(userId), [userId]);
28
29  if (isLoading) {
30    return (
31      <div className="space-y-4">
32        <div className="flex items-center justify-between">
33          <h2 className="text-2xl font-bold">Your Dashboard</h2>
34          <div>
35            <Spinner size="md" />
36          </div>
37        </div>
38
39        <div className="grid grid-cols-3 gap-4">
40          {Array.from({ length: 6 }).map((_, i) => (
41            <ProductCardSkeleton key={i} />
42          ))}
43        </div>
44      </div>
45    );
46  }
47
48  if (isError) {
49    return (
50      <div className="rounded-lg bg-red-50 p-6 text-center">
51        <h3 className="text-lg font-medium text-red-800">An error occurred</h3>
52        <p className="mt-2 text-red-600">{error?.message}</p>
53        <button
54          onClick={reload}
55          className="mt-4 rounded-md bg-red-100 px-4 py-2 text-red-800 hover:bg-red-200"
56        >
57          Try again
58        </button>
59      </div>
60    );
61  }
62
63  if (!data) {
64    return null;
65  }
66
67  return (
68    <div className="space-y-6">
69      <div className="flex items-center justify-between">
70        <h2 className="text-2xl font-bold">Welcome, {data.user.name}</h2>
71        <button
72          onClick={reload}
73          className="rounded-md bg-gray-100 p-2 hover:bg-gray-200"
74          aria-label="Refresh data"
75        >
76          <RefreshIcon className="h-5 w-5" />
77        </button>
78      </div>
79
80      <div className="grid grid-cols-3 gap-4">
81        {data.recentlyViewed.map(product => (
82          <ProductCard key={product.id} product={product} />
83        ))}
84      </div>
85
86      {/* Other dashboard sections */}
87    </div>
88  );
89}
90
91// Icon component
92function RefreshIcon({ className }: { className?: string }) {
93  return (
94    <svg
95      xmlns="http://www.w3.org/2000/svg"
96      viewBox="0 0 24 24"
97      fill="none"
98      stroke="currentColor"
99      strokeWidth="2"
100      strokeLinecap="round"
101      strokeLinejoin="round"
102      className={className}
103    >
104      <path d="M21 2v6h-6" />
105      <path d="M3 12a9 9 0 0 1 15-6.7L21 8" />
106      <path d="M3 22v-6h6" />
107      <path d="M21 12a9 9 0 0 1-15 6.7L3 16" />
108    </svg>
109  );
110}

Summary and Best Practices

Here are the key principles for creating effective loading skeletons and progress indicators in Next.js:

  1. Match the content - loading skeletons should reflect the shape and proportions of the actual content

  2. Display strategies:

    • For fast operations (< 300ms) - consider no indicator to avoid flickering
    • For medium operations (300ms - 1s) - use skeletons or spinners
    • For long operations (> 1s) - use progress indicators with actual progress (if possible)
  3. Content prioritization - show critical UI elements first:

    • Header and navigation
    • Main page content
    • Auxiliary elements (filters, side panels) last
  4. Minimize CLS - always define dimensions and proportions for loading skeletons

  5. Accessibility - remember the accessibility of loading indicators:

    • Use appropriate ARIA attributes (aria-busy, aria-label)
    • Provide text alternatives for visual indicators
    • Avoid flashing animations that can be problematic for people with epilepsy
  6. Optimization for mobile devices - adapt loading indicators to different screen sizes and use appropriate skeletons for mobile layouts

  7. Visual consistency - maintain a consistent look for loading skeletons with the rest of the user interface (colors, rounded corners, spacing)

In times when users expect an instant response, well-designed loading states are a key element of user experience. With the techniques described in this module, you can create applications that seem to work faster and provide a smoother experience, even when data is loading in the background.

In the next module, we will discuss partial prerendering, which is the next step in optimizing the performance of Next.js applications and allows for an even better user experience.

Go to CodeWorlds