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.
Szkielety ładowania (loading skeletons) i wskaźniki postępu to kluczowe elementy nowoczesnych aplikacji internetowych z kilku powodów:
Istnieje kilka podejść do informowania użytkownika o stanie ładowania:
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.
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}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.
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// 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">Ładowanie...</span>
39 </div>
40 );
41}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// 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}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}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}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// 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}Oprócz wskaźników ładowania dla danych przychodzących, warto również pokazywać stan operacji wysyłania danych.
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('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}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// 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 // 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}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// 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}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}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// 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 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}Oto kluczowe zasady dotyczące tworzenia efektywnych szkieletów ładowania i wskaźników postępu w Next.js:
Dopasowanie do treści - szkielety ładowania powinny odzwierciedlać kształt i proporcje rzeczywistej zawartości
Strategie wyświetlania:
Priorytyzacja zawartości - pokazuj najpierw krytyczne elementy UI:
Minimalizacja CLS - zawsze definiuj wymiary i proporcje dla szkieletów ładowania
Dostępność - pamiętaj o dostępności wskaźników ładowania:
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
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.