Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

Skelety ładowania i wskaźniki postępu

W poprzednim module omówiliśmy, jak używać React Suspense i streamowania danych, aby poprawić wydajność ładowania i responsywność interfejsu użytkownika. Jednak samo oczekiwanie na ładowanie danych bez odpowiedniego informowania użytkownika o tym, co się dzieje, może powodować frustrację i wrażenie, że aplikacja nie działa. W tym module skupimy się na tworzeniu zaawansowanych szkieletów ładowania i wskaźników postępu, które uzupełniają strategię streamingu danych i zapewniają doskonałe doświadczenie użytkownika.

Dlaczego szkielety ładowania są ważne?

Szkielety ładowania (loading skeletons) i wskaźniki postępu to kluczowe elementy nowoczesnych aplikacji internetowych z kilku powodów:

  1. Redukują postrzeganą latencję - nawet jeśli faktyczny czas ładowania się nie zmienia, użytkownicy postrzegają go jako krótszy, gdy widzą, że coś się dzieje
  2. Zapobiegają przeskokom układu (CLS) - utrzymują strukturę strony, zmniejszając przeskoki podczas ładowania
  3. Komunikują stan aplikacji - informują użytkownika, że dane są ładowane, zamiast pozostawiać go w niepewności
  4. Poprawiają metryki Core Web Vitals - mniejszy CLS i lepszy LCP (Largest Contentful Paint) przekładają się na lepsze wskaźniki wydajności

Rodzaje wskaźników stanu ładowania

Istnieje kilka podejść do informowania użytkownika o stanie ładowania:

  1. Szkielety ładowania (Skeleton UI) - uproszczone reprezentacje graficzne treści, które będą wyświetlane
  2. Wskaźniki postępu - wizualne reprezentacje postępu ładowania (paski, kółka)
  3. Indeterminate loaders - animowane wskaźniki (np. kręcące się koła), które nie pokazują dokładnego postępu
  4. Opóźnione renderowanie - pokazywanie wskaźników ładowania tylko po określonym czasie, aby uniknąć migotania
  5. Animacje przejścia - płynne przejścia między stanami ładowania a gotową zawartością

Implementacja szkieletów ładowania w Next.js

1. Podstawowe komponenty szkieletowe

Zacznijmy od utworzenia podstawowych komponentów szkieletowych, które możemy używać jako elementy zastępcze podczas ładowania:

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}

Ten prosty komponent tworzy pulsujący element, który można dostosowywać za pomocą klas i właściwości. Możemy teraz używać go do tworzenia różnych typów szkieletów ładowania.

2. Zaawansowane szkielety dla poszczególnych typów zawartości

Możemy tworzyć bardziej zaawansowane szkielety dla konkretnych typów zawartości, jak karty produktów, tabele czy listy:

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      {/* Szkielet obrazu produktu */}
8      <Skeleton className="h-[200px] w-full rounded-lg" />
9      
10      {/* Szkielet nazwy produktu */}
11      <Skeleton className="h-8 w-3/4" />
12      
13      {/* Szkielet ceny i oceny */}
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      {/* Szkielet przycisku */}
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      {/* Nagłówek tabeli */}
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      {/* Wiersze tabeli */}
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. Używanie szkieletów z React Suspense

Teraz możemy połączyć nasze szkielety ładowania z React Suspense, aby używać ich jako fallbacków podczas streamowania danych:

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">Nasze produkty</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}

W tym przykładzie tworzymy szkielety dla filtrów produktów i siatki produktów, dzięki czemu podczas ładowania użytkownik widzi odpowiednią strukturę strony.

Wskaźniki postępu w Next.js

Oprócz szkieletów ładowania, możemy również używać wskaźników postępu, aby informować użytkownika o stanie operacji, szczególnie tych, które zajmują więcej czasu.

1. Prosty wskaźnik postępu

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. Nieokreślony wskaźnik ładowania (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">Ładowanie...</span>
39    </div>
40  );
41}

Wskaźniki postępu na poziomie całej aplikacji

W Next.js możemy również implementować wskaźniki postępu na poziomie całej aplikacji, które informują użytkownika o stanie nawigacji między stronami.

1. Globalny wskaźnik postępu przy przejściach stron

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    // Konfiguracja NProgress
16    NProgress.configure({ showSpinner: false });
17    
18    // Obsługa rozpoczęcia nawigacji
19    function onRouteChangeStart() {
20      setIsNavigating(true);
21      NProgress.start();
22    }
23    
24    // Obsługa zakończenia nawigacji
25    function onRouteChangeComplete() {
26      setIsNavigating(false);
27      NProgress.done();
28    }
29    
30    // Dodanie nasłuchiwaczy zdarzeń
31    window.addEventListener("beforeunload", onRouteChangeStart);
32    
33    // Usunięcie nasłuchiwaczy zdarzeń po odmontowaniu komponentu
34    return () => {
35      window.removeEventListener("beforeunload", onRouteChangeStart);
36    };
37  }, []);
38  
39  // Reagowanie na zmiany ścieżki lub parametrów wyszukiwania
40  useEffect(() => {
41    if (isNavigating) {
42      NProgress.done();
43      setIsNavigating(false);
44    }
45  }, [pathname, searchParams, isNavigating]);
46  
47  return null; // Ten komponent nie renderuje niczego, tylko zarządza NProgress
48}

Następnie możemy dodać ten komponent do naszego layout.tsx:

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

Zaawansowane techniki zarządzania stanami ładowania

1. Opóźnione renderowanie wskaźników ładowania

W niektórych przypadkach ładowanie danych jest tak szybkie, że pokazywanie wskaźnika ładowania powoduje niepotrzebne migotanie interfejsu. Możemy rozwiązać ten problem poprzez opóźnienie renderowania wskaźnika ładowania:

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}

Teraz możemy użyć tego hooka w naszych komponentach:

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 z opóźnieniem

Możemy również stworzyć własny komponent oparty na Suspense, który wprowadza opóźnienie:

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}

Zarządzanie Cumulative Layout Shift (CLS)

Przeskoki układu (CLS) to jedna z kluczowych metryk Core Web Vitals. Dobrze zaprojektowane szkielety ładowania mogą pomóc w minimalizacji przeskoków układu, zachowując wymiary i proporcje elementów.

1. Zachowanie wymiarów

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. Ładowanie obrazów z zachowaniem miejsca

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}

Wskaźniki stanu wysyłania formularzy

Oprócz wskaźników ładowania dla danych przychodzących, warto również pokazywać stan operacji wysyłania danych.

1. Przycisk z wskaźnikiem ładowania

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. Formularz z wskaźnikiem stanu

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('Wystąpił problem przy wysyłaniu wiadomości');
28      }
29      
30      setSuccess(true);
31      event.currentTarget.reset();
32      
33      // Przekierowanie po udanym wysłaniu
34      setTimeout(() => {
35        router.push('/contact/thank-you');
36      }, 1500);
37    } catch (err) {
38      setError(err instanceof Error ? err.message : 'Wystąpił nieznany błąd');
39    } finally {
40      setLoading(false);
41    }
42  }
43  
44  return (
45    <form onSubmit={handleSubmit} className="space-y-6">
46      {/* Pola formularza */}
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      {/* Stan formularza */}
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          Wiadomość została wysłana pomyślnie!
80        </div>
81      )}
82      
83      {/* Przycisk wysyłania */}
84      <LoadingButton 
85        type="submit" 
86        loading={loading}
87        className="w-full"
88      >
89        Wyślij wiadomość
90      </LoadingButton>
91    </form>
92  );
93}

Wskaźniki postępu dla długotrwałych operacji

Dla operacji, które trwają dłużej, warto pokazywać wskaźniki postępu, które informują użytkownika o aktualnym stanie i pozostałym czasie.

1. Wskaźnik postępu z procentami

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. Wskaźnik postępu dla przesyłania plików

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    // Sprawdzenie rozmiaru pliku
27    if (file.size > maxSizeMB * 1024 * 1024) {
28      setError(`Plik jest zbyt duży. Maksymalny rozmiar to ${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      // Nasłuchiwanie postępu przesyłania
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      // Obsługa zakończenia przesyłania
51      xhr.addEventListener('load', () => {
52        if (xhr.status === 200) {
53          const response = JSON.parse(xhr.responseText);
54          onUploadComplete(response.url);
55        } else {
56          setError('Wystąpił błąd podczas przesyłania pliku');
57        }
58        setUploading(false);
59      });
60      
61      // Obsługa błędów
62      xhr.addEventListener('error', () => {
63        setError('Wystąpił błąd sieci podczas przesyłania pliku');
64        setUploading(false);
65      });
66      
67      // Wysyłka pliku
68      xhr.open('POST', '/api/upload');
69      xhr.send(formData);
70    } catch (err) {
71      setError('Wystąpił nieoczekiwany błąd');
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">Wybierz plik:</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="Przesyłanie pliku"
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}

Integracja z React Suspense i Server Components

W Next.js możemy łączyć nasze szkielety ładowania i wskaźniki postępu z React Suspense i Server Components, aby stworzyć płynne doświadczenie użytkownika.

1. Dynamiczna strona produktów z różnymi wskaźnikami ładowania

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      {/* Priorytet 1: Nagłówek strony - mała latencja */}
20      <Suspense fallback={<ProductHeaderSkeleton />}>
21        <ProductHeader />
22      </Suspense>
23      
24      {/* Priorytet 2: Wyszukiwarka - małe opóźnienie */}
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        {/* Priorytet 3: Filtry - opóźnione renderowanie */}
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        {/* Priorytet 4: Siatka produktów - z pełnym szkieletem */}
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. Implementacja komponentów produktów

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  // Konwersja parametrów
14  const minPrice = (await params).minPrice ? parseInt((await params).minPrice) : undefined;
15  const maxPrice = (await params).maxPrice ? parseInt((await params).maxPrice) : undefined;
16  
17  // Symulacja opóźnienia sieci dla demonstracji szkieletów ładowania
18  await new Promise(resolve => setTimeout(resolve, 1500));
19  
20  // Prawdziwa implementacja pobierałaby dane z API lub bazy danych
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    // W rzeczywistej implementacji możesz użyć cache, revalidate itp.
31    cache: 'no-store'
32  });
33  
34  if (!response.ok) {
35    throw new Error('Problem z pobieraniem produktów');
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">Nie znaleziono produktów</h3>
48        <p className="mt-2 text-gray-500">
49          Spróbuj zmienić kryteria wyszukiwania lub filtry
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}

Wskaźniki stanu ładowania w komponentach klienta

W komponentach klienta używamy innych metod do wyświetlania wskaźników stanu ładowania, ponieważ nie możemy użyć React Suspense dla asynchronicznego pobierania danych w tych komponentach.

1. Hook useAsyncData dla wskaźników ładowania

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. Wykorzystanie hooka w komponencie klienta

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 z pobieraniem danych dashboardu');
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">Twój 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">Wystąpił błąd</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          Spróbuj ponownie
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">Witaj, {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="Odśwież dane"
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      {/* Inne sekcje dashboardu */}
87    </div>
88  );
89}
90
91// Komponent ikony
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}

Podsumowanie i najlepsze praktyki

Oto kluczowe zasady dotyczące tworzenia efektywnych szkieletów ładowania i wskaźników postępu w Next.js:

  1. Dopasowanie do treści - szkielety ładowania powinny odzwierciedlać kształt i proporcje rzeczywistej zawartości

  2. Strategie wyświetlania:

    • Dla szybkich operacji (< 300ms) - rozważ brak wskaźnika, aby uniknąć migotania
    • Dla średnich operacji (300ms - 1s) - użyj szkieletów lub spinnerów
    • Dla długich operacji (> 1s) - używaj wskaźników postępu z rzeczywistym postępem (jeśli to możliwe)
  3. Priorytyzacja zawartości - pokazuj najpierw krytyczne elementy UI:

    • Nagłówek i nawigacja
    • Główna zawartość strony
    • Pomocnicze elementy (filtry, boczne panele) na końcu
  4. Minimalizacja CLS - zawsze definiuj wymiary i proporcje dla szkieletów ładowania

  5. Dostępność - pamiętaj o dostępności wskaźników ładowania:

    • Używaj odpowiednich atrybutów ARIA (aria-busy, aria-label)
    • Zapewniaj tekstowe alternatywy dla wskaźników wizualnych
    • Unikaj migających animacji, które mogą być problematyczne dla osób z epilepsją
  6. Optymalizacja dla urządzeń mobilnych - dostosuj wskaźniki ładowania do różnych rozmiarów ekranów i używaj odpowiednich szkieletów dla układów mobilnych

  7. Spójność wizualna - utrzymuj spójny wygląd szkieletów ładowania z resztą interfejsu użytkownika (kolory, zaokrąglenia, odstępy)

W czasach, gdy użytkownicy oczekują natychmiastowej reakcji, dobrze zaprojektowane stany ładowania są kluczowym elementem doświadczenia użytkownika. Dzięki technikom opisanym w tym module możesz stworzyć aplikacje, które wydają się działać szybciej i zapewniają płynniejsze doświadczenie, nawet gdy dane ładują się w tle.

W następnym module omówimy częściowe prerenderowanie, które jest kolejnym krokiem w optymalizacji wydajności aplikacji Next.js i pozwala na jeszcze lepsze doświadczenie użytkownika.

Przejdź do CodeWorlds