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.
Loading skeletons and progress indicators are key elements of modern web applications for several reasons:
There are several approaches to informing the user about the loading state:
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.
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}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.
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// 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 };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}In Next.js, we can also implement application-wide progress indicators that inform the user about the navigation state between pages.
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}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}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}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// 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}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}In addition to loading indicators for incoming data, it's also worth showing the state of data submission operations.
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}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}For operations that take longer, it's worth showing progress indicators that inform the user about the current state and remaining time.
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}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}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// 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}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}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// 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}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}Here are the key principles for creating effective loading skeletons and progress indicators in Next.js:
Match the content - loading skeletons should reflect the shape and proportions of the actual content
Display strategies:
Content prioritization - show critical UI elements first:
Minimize CLS - always define dimensions and proportions for loading skeletons
Accessibility - remember the accessibility of loading indicators:
Optimization for mobile devices - adapt loading indicators to different screen sizes and use appropriate skeletons for mobile layouts
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.