Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React

Projekt: Zaawansowana aplikacja React 18+

12 min czytania
W tej lekcji4

Każda załoga przechodzi kiedyś symulację pełnej misji, w której wszystkie systemy działają naraz. Znasz już przejścia, opóźnione wartości, useId, use i useOptimistic, ale osobno. Teraz muszą współpracować w jednym kodzie.

W tym projekcie stworzysz nowoczesną aplikację React wykorzystującą wszystkie poznane funkcjonalności React 18+. Będziesz budować Advanced Task Management System - zaawansowany system zarządzania zadaniami z wykorzystaniem Concurrent Features, Server Components i nowoczesnych hooków.

Specyfikacja projektu

Funkcjonalności główne

  1. Panel z aktualizacjami na żywo

    • Wykorzystanie useTransition i useDeferredValue
    • Optymistyczne aktualizacje z useOptimistic
    • Streaming zawartości z Suspense
  2. Zaawansowane formularze

    • useFormStatus i useActionState
    • Server Actions
    • Walidacja w czasie rzeczywistym
  3. System powiadomień

    • useId dla dostępności
    • Concurrent rendering
    • Progressive enhancement
  4. Wydajność i optymalizacja

    • React Compiler (opcjonalnie)
    • Automatic Batching
    • Selective Hydration

React Compiler ma od 2025 roku stabilną wersję 1.0 i sam dodaje memoizację, więc warto go rozważyć.

Struktura aplikacji

Projekt korzysta z App Routera Next.js, czyli frameworka, który obsługuje Server Components i Server Actions. Oto mapa katalogów:

1src/
2├── app/
3│   ├── layout.tsx          # Root layout (Server Component)
4│   ├── page.tsx            # Home page
5│   ├── dashboard/
6│   │   ├── page.tsx        # Dashboard (Server Component)
7│   │   └── loading.tsx     # Loading UI
8│   ├── tasks/
9│   │   ├── page.tsx        # Tasks list
10│   │   ├── [id]/page.tsx   # Task details
11│   │   └── new/page.tsx    # Create task
12│   └── api/
13│       ├── tasks/route.ts  # Tasks API
14│       └── notifications/route.ts
15├── components/
16│   ├── ui/                 # Basic UI components
17│   ├── forms/              # Form components
18│   ├── dashboard/          # Dashboard components
19│   └── tasks/              # Task components
20├── hooks/                  # Custom hooks
21├── lib/                    # Utilities
22└── types/                  # TypeScript types

Folder app/ to trasy, components/ to klocki UI, a lib/ to logika serwerowa.

Implementacja krok po kroku

Krok 1: Podstawowa struktura i typy

Zaczynamy od danych. Interfejs TypeScript to kontrakt opisujący pola zadania, użytkownika i powiadomienia:

1// types/index.ts
2export interface Task {
3  id: string;
4  title: string;
5  description: string;
6  status: 'pending' | 'in_progress' | 'completed';
7  priority: 'low' | 'medium' | 'high';
8  assignee?: string;
9  dueDate?: Date;
10  createdAt: Date;
11  updatedAt: Date;
12}
13
14export interface User {
15  id: string;
16  name: string;
17  email: string;
18  avatar?: string;
19}
20
21export interface Notification {
22  id: string;
23  type: 'task_created' | 'task_updated' | 'task_completed';
24  message: string;
25  timestamp: Date;
26  read: boolean;
27}

Pola ze znakiem zapytania, jak assignee?, są opcjonalne, a unie typu 'low' | 'medium' | 'high' pozwalają tylko na wymienione wartości.

Krok 2: Root Layout z Server Components

Layout to asynchroniczny Server Component, więc może pobrać dane użytkownika jeszcze na serwerze:

1// app/layout.tsx (Server Component)
2import { Suspense } from 'react';
3import { Navigation } from '@/components/Navigation';
4import { NotificationProvider } from '@/components/NotificationProvider';
5
6async function RootLayout({ children }: { children: React.ReactNode }) {
7  // Pobierz dane użytkownika na serwerze
8  const user = await getCurrentUser();
9  const notifications = await getUnreadNotifications(user.id);
10
11  return (
12    <html lang="pl">
13      <head>
14        <title>Advanced Task Manager</title>
15        <meta name="description" content="Zaawansowany system zarządzania zadaniami" />
16      </head>
17      <body>
18        <NotificationProvider initialNotifications={notifications}>
19          <div className="app-layout">
20            <Suspense fallback={<NavigationSkeleton />}>
21              <Navigation user={user} />
22            </Suspense>
23
24            <main className="main-content">
25              <Suspense fallback={<PageSkeleton />}>
26                {children}
27              </Suspense>
28            </main>
29          </div>
30        </NotificationProvider>
31      </body>
32    </html>
33  );
34}
35
36export default RootLayout;

Dwie granice Suspense pozwalają nawigacji i treści pojawiać się niezależnie. W Next.js tytuł i opis strony definiuje się zwykle przez eksport metadata, a nie ręczny <head>.

Krok 3: Dashboard z Concurrent Features

Strona dashboardu pobiera równolegle statystyki i ostatnie zadania, a wolniejsze sekcje strumieniuje:

1// app/dashboard/page.tsx (Server Component)
2import { Suspense } from 'react';
3import { TasksOverview } from '@/components/dashboard/TasksOverview';
4import { RecentActivity } from '@/components/dashboard/RecentActivity';
5import { PerformanceMetrics } from '@/components/dashboard/PerformanceMetrics';
6
7async function DashboardPage() {
8  // Równoległe pobieranie danych
9  const [tasksStats, recentTasks] = await Promise.all([
10    getTasksStatistics(),
11    getRecentTasks(10)
12  ]);
13
14  return (
15    <div className="dashboard">
16      <h1>Dashboard</h1>
17
18      {/* Szybko ładująca się sekcja */}
19      <TasksOverview stats={tasksStats} />
20
21      <div className="dashboard-grid">
22        {/* Streams niezależnie */}
23        <Suspense fallback={<RecentActivitySkeleton />}>
24          <RecentActivity initialTasks={recentTasks} />
25        </Suspense>
26
27        {/* Może ładować się później */}
28        <Suspense fallback={<MetricsSkeleton />}>
29          <PerformanceMetrics />
30        </Suspense>
31      </div>
32    </div>
33  );
34}
35
36export default DashboardPage;

TasksOverview pojawia się od razu, a PerformanceMetrics doczytuje się później.

Krok 4: Interaktywne komponenty z nowymi hookami

Dyrektywa 'use client' oznacza granicę, za którą kod trafia do przeglądarki i może używać stanu:

1// components/dashboard/RecentActivity.tsx (Client Component)
2'use client';
3
4import { useState, useTransition, useDeferredValue } from 'react';
5import { Task } from '@/types';
6
7interface RecentActivityProps {
8  initialTasks: Task[];
9}
10
11export function RecentActivity({ initialTasks }: RecentActivityProps) {
12  const [tasks, setTasks] = useState(initialTasks);
13  const [searchQuery, setSearchQuery] = useState('');
14  const [isPending, startTransition] = useTransition();
15
16  // Opóźniona wartość dla wyszukiwania
17  const deferredSearchQuery = useDeferredValue(searchQuery);
18
19  // Filtrowanie zadań z wykorzystaniem deferredValue
20  const filteredTasks = tasks.filter(task =>
21    task.title.toLowerCase().includes(deferredSearchQuery.toLowerCase()) ||
22    task.description.toLowerCase().includes(deferredSearchQuery.toLowerCase())
23  );
24
25  const handleSearch = (query: string) => {
26    setSearchQuery(query);
27
28    // Użyj transition dla mniej priorytetowych aktualizacji
29    startTransition(() => {
30      // Dodatkowe operacje które mogą być przerwane
31      updateSearchAnalytics(query);
32    });
33  };
34
35  const refreshTasks = async () => {
36    startTransition(async () => {
37      const newTasks = await fetchRecentTasks();
38      setTasks(newTasks);
39    });
40  };
41
42  return (
43    <div className="recent-activity">
44      <div className="activity-header">
45        <h2>Ostatnia aktywność</h2>
46        <button
47          onClick={refreshTasks}
48          disabled={isPending}
49          className={isPending ? 'loading' : ''}
50        >
51          {isPending ? 'Odświeżanie...' : 'Odśwież'}
52        </button>
53      </div>
54
55      <SearchInput
56        value={searchQuery}
57        onChange={handleSearch}
58        isPending={isPending}
59      />
60
61      <div className="tasks-list">
62        {filteredTasks.map(task => (
63          <TaskCard
64            key={task.id}
65            task={task}
66            onUpdate={setTasks}
67          />
68        ))}
69
70        {deferredSearchQuery && filteredTasks.length === 0 && (
71          <div className="no-results">
72            Brak wyników dla "{deferredSearchQuery}"
73          </div>
74        )}
75      </div>
76    </div>
77  );
78}

Lista filtruje się według deferredSearchQuery, więc pole wyszukiwania nie zwalnia. Dwie uwagi: startTransition w handleSearch nie przerywa samego wywołania updateSearchAnalytics, bo przerywalne są tylko rendery po zmianach stanu. A setTasks po await powinien zgodnie z react.dev trafić do kolejnego startTransition.

Krok 5: Zaawansowane formularze z Server Actions

Formularz łączy trzy hooki. useActionState trzyma wynik akcji, useOptimistic pokazuje zapis od razu, a useFormStatus informuje przycisk o wysyłce. Ważne: useFormStatus importujemy z react-dom, a nie z react. Najpierw logika:

1// components/forms/TaskForm.tsx (Client Component)
2'use client';
3
4import { useActionState, useEffect, useId, useOptimistic } from 'react';
5import { useFormStatus } from 'react-dom';
6import { Task } from '@/types';
7import { createTask, updateTask } from '@/lib/actions';
8
9interface TaskFormProps {
10  task?: Task;
11  onSuccess?: () => void;
12}
13
14export function TaskForm({ task, onSuccess }: TaskFormProps) {
15  const formId = useId();
16  const titleId = useId();
17  const descriptionId = useId();
18
19  // useActionState dla zarządzania stanem akcji
20  const [state, formAction] = useActionState(
21    task ? updateTask.bind(null, task.id) : createTask,
22    {
23      success: false,
24      errors: {},
25      data: task || null
26    }
27  );
28
29  // useOptimistic dla natychmiastowej informacji zwrotnej
30  const [optimisticState, addOptimistic] = useOptimistic(
31    state,
32    (currentState, optimisticUpdate) => ({
33      ...currentState,
34      ...optimisticUpdate,
35      optimistic: true
36    })
37  );
38
39  const handleSubmit = async (formData: FormData) => {
40    // Optymistyczna aktualizacja
41    addOptimistic({
42      success: true,
43      data: {
44        id: task?.id || 'temp-' + Date.now(),
45        title: formData.get('title') as string,
46        description: formData.get('description') as string,
47        status: formData.get('status') as Task['status'],
48        priority: formData.get('priority') as Task['priority'],
49        createdAt: new Date(),
50        updatedAt: new Date()
51      }
52    });
53
54    // Wykonaj rzeczywistą akcję
55    formAction(formData);
56  };
57
58  // Nowy state pojawia się w kolejnym renderze, więc reagujemy na niego w efekcie
59  useEffect(() => {
60    if (state.success) {
61      onSuccess?.();
62    }
63  }, [state, onSuccess]);
64

Funkcja formAction nie zwraca Promise, a state w tym domknięciu to jeszcze stary stan. Dlatego na sukces reagujemy w efekcie, gdy nowy state przyjdzie w kolejnym renderze. Teraz widok formularza:

1  return (
2    <form
3      id={formId}
4      action={handleSubmit}
5      className="task-form"
6    >
7      <div className="form-group">
8        <label htmlFor={titleId}>Tytuł zadania</label>
9        <input
10          id={titleId}
11          name="title"
12          type="text"
13          required
14          defaultValue={optimisticState.data?.title || ''}
15          className={optimisticState.errors?.title ? 'error' : ''}
16        />
17        {optimisticState.errors?.title && (
18          <span className="error-message" role="alert">
19            {optimisticState.errors.title}
20          </span>
21        )}
22      </div>
23
24      <div className="form-group">
25        <label htmlFor={descriptionId}>Opis</label>
26        <textarea
27          id={descriptionId}
28          name="description"
29          rows={4}
30          defaultValue={optimisticState.data?.description || ''}
31        />
32      </div>
33
34      <div className="form-group">
35        <label>Status</label>
36        <select name="status" defaultValue={optimisticState.data?.status || 'pending'}>
37          <option value="pending">Oczekujące</option>
38          <option value="in_progress">W trakcie</option>
39          <option value="completed">Zakończone</option>
40        </select>
41      </div>
42
43      <div className="form-group">
44        <label>Priorytet</label>
45        <select name="priority" defaultValue={optimisticState.data?.priority || 'medium'}>
46          <option value="low">Niski</option>
47          <option value="medium">Średni</option>
48          <option value="high">Wysoki</option>
49        </select>
50      </div>
51
52      <FormSubmitButton />
53
54      {optimisticState.optimistic && (
55        <div className="optimistic-indicator">
56          Zapisywanie...
57        </div>
58      )}
59
60      {optimisticState.success && !optimisticState.optimistic && (
61        <div className="success-message">
62          Zadanie zostało {task ? 'zaktualizowane' : 'utworzone'}!
63        </div>
64      )}
65    </form>
66  );
67}
68
69// Komponent wykorzystujący useFormStatus
70function FormSubmitButton() {
71  const { pending } = useFormStatus();
72
73  return (
74    <button
75      type="submit"
76      disabled={pending}
77      className={pending ? 'loading' : ''}
78    >
79      {pending ? 'Zapisywanie...' : 'Zapisz zadanie'}
80    </button>
81  );
82}

FormSubmitButton jest osobnym komponentem, bo useFormStatus czyta stan formularza rodzica, więc musi być wywołany wewnątrz <form>.

Krok 6: Server Actions

Dyrektywa 'use server' sprawia, że te funkcje wykonują się tylko na serwerze. Walidację robimy biblioteką Zod:

1// lib/actions.ts
2'use server';
3
4import { revalidatePath } from 'next/cache';
5import { redirect } from 'next/navigation';
6import { z } from 'zod';
7
8const TaskSchema = z.object({
9  title: z.string().min(1, 'Tytuł jest wymagany').max(100, 'Tytuł jest za długi'),
10  description: z.string().optional(),
11  status: z.enum(['pending', 'in_progress', 'completed']),
12  priority: z.enum(['low', 'medium', 'high']),
13  dueDate: z.string().optional().transform(str => str ? new Date(str) : undefined)
14});
15
16export async function createTask(prevState: any, formData: FormData) {
17  try {
18    const validatedFields = TaskSchema.safeParse({
19      title: formData.get('title'),
20      description: formData.get('description'),
21      status: formData.get('status'),
22      priority: formData.get('priority'),
23      dueDate: formData.get('dueDate')
24    });
25
26    if (!validatedFields.success) {
27      return {
28        success: false,
29        errors: z.flattenError(validatedFields.error).fieldErrors,
30        data: null
31      };
32    }
33
34    const task = await db.tasks.create({
35      data: {
36        ...validatedFields.data,
37        id: generateId(),
38        createdAt: new Date(),
39        updatedAt: new Date()
40      }
41    });
42
43    // Revalidate cache
44    revalidatePath('/dashboard');
45    revalidatePath('/tasks');
46
47    return {
48      success: true,
49      errors: {},
50      data: task
51    };
52
53  } catch (error) {
54    return {
55      success: false,
56      errors: { _form: ['Wystąpił błąd podczas tworzenia zadania'] },
57      data: null
58    };
59  }
60}
61

safeParse nie rzuca wyjątku, tylko zwraca obiekt z polem success. Błędy spłaszczamy przez z.flattenError, bo w Zod 4 metoda error.flatten() jest oznaczona jako przestarzała. Akcja edycji wygląda podobnie:

1export async function updateTask(taskId: string, prevState: any, formData: FormData) {
2  try {
3    const validatedFields = TaskSchema.safeParse({
4      title: formData.get('title'),
5      description: formData.get('description'),
6      status: formData.get('status'),
7      priority: formData.get('priority'),
8      dueDate: formData.get('dueDate')
9    });
10
11    if (!validatedFields.success) {
12      return {
13        success: false,
14        errors: z.flattenError(validatedFields.error).fieldErrors,
15        data: null
16      };
17    }
18
19    const task = await db.tasks.update({
20      where: { id: taskId },
21      data: {
22        ...validatedFields.data,
23        updatedAt: new Date()
24      }
25    });
26
27    revalidatePath(`/tasks/${taskId}`);
28    revalidatePath('/dashboard');
29
30    return {
31      success: true,
32      errors: {},
33      data: task
34    };
35
36  } catch (error) {
37    return {
38      success: false,
39      errors: { _form: ['Wystąpił błąd podczas aktualizacji zadania'] },
40      data: null
41    };
42  }
43}

updateTask dostaje taskId jako pierwszy argument dzięki bind w formularzu, a revalidatePath odświeża dane stron.

Krok 7: Zaawansowane wzorce Suspense

Granice Suspense można zagnieżdżać, aż do pojedynczej karty:

1// components/tasks/TasksList.tsx (Server Component)
2import { Suspense } from 'react';
3import { TaskCard } from './TaskCard';
4import { TasksFilter } from './TasksFilter';
5
6async function TasksList({ searchParams }: { searchParams: any }) {
7  return (
8    <div className="tasks-page">
9      <div className="tasks-header">
10        <h1>Zadania</h1>
11
12        {/* Filter nie wymaga Suspense - szybko się ładuje */}
13        <TasksFilter />
14      </div>
15
16      <div className="tasks-content">
17        {/* Główna lista zadań */}
18        <Suspense fallback={<TasksListSkeleton />}>
19          <TasksGrid searchParams={searchParams} />
20        </Suspense>
21
22        {/* Sidebar z dodatkowymi informacjami */}
23        <aside className="tasks-sidebar">
24          <Suspense fallback={<StatsSkeleton />}>
25            <TasksStatistics />
26          </Suspense>
27
28          <Suspense fallback={<ActivitySkeleton />}>
29            <RecentActivity />
30          </Suspense>
31        </aside>
32      </div>
33    </div>
34  );
35}
36
37// Komponent z długim czasem ładowania
38async function TasksGrid({ searchParams }: { searchParams: any }) {
39  // Symuluj długie zapytanie
40  const tasks = await getFilteredTasks(searchParams);
41
42  return (
43    <div className="tasks-grid">
44      {tasks.map(task => (
45        <Suspense
46          key={task.id}
47          fallback={<TaskCardSkeleton />}
48        >
49          <TaskCard task={task} />
50        </Suspense>
51      ))}
52    </div>
53  );
54}

Od Next.js 15 searchParams na stronie jest obiektem Promise, więc przed użyciem trzeba go odczytać przez await.

Krok 8: Dostępność z useId

Uniwersalne pole formularza wstrzykuje identyfikatory do swojego dziecka:

1// components/ui/FormField.tsx
2'use client';
3
4import { useId, forwardRef, cloneElement } from 'react';
5
6interface FormFieldProps {
7  label: string;
8  error?: string;
9  help?: string;
10  required?: boolean;
11  children: React.ReactNode;
12}
13
14export const FormField = forwardRef<HTMLDivElement, FormFieldProps>(
15  ({ label, error, help, required, children }, ref) => {
16    const fieldId = useId();
17    const errorId = useId();
18    const helpId = useId();
19
20    return (
21      <div ref={ref} className="form-field">
22        <label
23          htmlFor={fieldId}
24          className={required ? 'required' : ''}
25        >
26          {label}
27          {required && <span aria-hidden="true">*</span>}
28        </label>
29
30        <div className="field-input">
31          {cloneElement(children as React.ReactElement, {
32            id: fieldId,
33            'aria-describedby': [
34              help ? helpId : null,
35              error ? errorId : null
36            ].filter(Boolean).join(' ') || undefined,
37            'aria-invalid': error ? 'true' : undefined,
38            required
39          })}
40        </div>
41
42        {help && (
43          <div id={helpId} className="field-help">
44            {help}
45          </div>
46        )}
47
48        {error && (
49          <div
50            id={errorId}
51            className="field-error"
52            role="alert"
53            aria-live="polite"
54          >
55            {error}
56          </div>
57        )}
58      </div>
59    );
60  }
61);
62
63FormField.displayName = 'FormField';

W React 19 ref jest zwykłym propsem komponentu funkcyjnego, a forwardRef ma zostać w przyszłości wycofany, więc w nowym kodzie możesz przyjąć ref wprost.

Krok 9: Monitorowanie wydajności

Na koniec kontekst zbierający metryki wydajności:

1// components/providers/PerformanceProvider.tsx
2'use client';
3
4import { createContext, useContext, useEffect, useState } from 'react';
5
6interface PerformanceMetrics {
7  renderTime: number;
8  interactionTime: number;
9  suspenseTime: number;
10}
11
12const PerformanceContext = createContext<{
13  metrics: PerformanceMetrics;
14  recordMetric: (type: keyof PerformanceMetrics, time: number) => void;
15}>({
16  metrics: { renderTime: 0, interactionTime: 0, suspenseTime: 0 },
17  recordMetric: () => {}
18});
19
20export function PerformanceProvider({ children }: { children: React.ReactNode }) {
21  const [metrics, setMetrics] = useState<PerformanceMetrics>({
22    renderTime: 0,
23    interactionTime: 0,
24    suspenseTime: 0
25  });
26
27  const recordMetric = (type: keyof PerformanceMetrics, time: number) => {
28    setMetrics(prev => ({
29      ...prev,
30      [type]: time
31    }));
32
33    // Wyślij metryki do systemu analitycznego
34    if (time > getThreshold(type)) {
35      analytics.track('performance_issue', {
36        type,
37        time,
38        url: window.location.pathname
39      });
40    }
41  };
42
43  useEffect(() => {
44    // Obserwuj Web Vitals
45    const observer = new PerformanceObserver((list) => {
46      for (const entry of list.getEntries()) {
47        if (entry.entryType === 'measure') {
48          recordMetric('renderTime', entry.duration);
49        }
50      }
51    });
52
53    observer.observe({ entryTypes: ['measure'] });
54
55    return () => observer.disconnect();
56  }, []);
57
58  return (
59    <PerformanceContext.Provider value={{ metrics, recordMetric }}>
60      {children}
61    </PerformanceContext.Provider>
62  );
63}
64
65export const usePerformance = () => useContext(PerformanceContext);
66
67function getThreshold(type: keyof PerformanceMetrics): number {
68  const thresholds = {
69    renderTime: 100,      // 100ms
70    interactionTime: 50,  // 50ms
71    suspenseTime: 1000    // 1s
72  };
73  return thresholds[type];
74}

PerformanceObserver słucha tu wpisów typu measure, czyli Twoich własnych pomiarów, a nie Web Vitals. Do Core Web Vitals użyj biblioteki web-vitals.

Podsumowanie projektu

Ten projekt demonstruje:

  1. Concurrent Features: useTransition, useDeferredValue dla responsywnego UI
  2. Obsługa formularzy: useActionState, useFormStatus z Server Actions
  3. Dostępność: useId dla unikalnych identyfikatorów
  4. Optymistyczne aktualizacje: useOptimistic dla natychmiastowej informacji zwrotnej
  5. Server Components: Renderowanie na serwerze z równoległym pobieraniem danych
  6. Wzorce Suspense: zagnieżdżone stany ładowania i strumieniowanie
  7. Wydajność: monitorowanie i optymalizacja
  8. Obsługa błędów: osobne Error Boundaries dla poszczególnych sekcji
  9. Bezpieczeństwo typów: pełne wykorzystanie TypeScriptu

Następne kroki do rozszerzenia:

  1. Dodaj synchronizację na żywo przez WebSockety
  2. Dodaj pracę offline z Service Workerem
  3. Dodaj zaawansowane filtry i sortowanie
  4. Stwórz system uprawnień użytkowników
  5. Zintegruj z systemem powiadomień push
  6. Dodaj eksport danych do różnych formatów
  7. Dodaj tryb ciemny z CSS-in-JS
  8. Stwórz mobilną aplikację z React Native

Ten projekt to solidna podstawa do budowania nowoczesnych aplikacji React wykorzystujących najnowsze funkcjonalności frameworka.

Moja rada: buduj krok po kroku i po każdym kroku uruchamiaj aplikację. Pamiętaj: dobra misja to taka, w której każdy system statku zna swoje zadanie, a centrum kontroli widzi wszystkie naraz.

Kod do tej lekcji: App.jsx
1import React, {
2  useState, useMemo, useCallback, memo,
3  useTransition, useDeferredValue, Suspense, lazy, createContext, use,
4} from 'react';
5
6// ========================
7// KONTEKSTY APLIKACJI
8// ========================
9const ThemeContext = createContext('dark');
10
11// ========================
12// DANE SYMULACYJNE
13// ========================
14function generateCrewMembers(count) {
15  const roles = ['Pilot', 'Nawigator', 'Inżynier', 'Medyk', 'Naukowiec', 'Technik'];
16  const statuses = ['Aktywny', 'Na misji', 'Odpoczynek', 'Szkolenie'];
17  const members = [];
18  for (let i = 0; i < count; i++) {
19    members.push({
20      id: i + 1,
21      name: `Kosmonauta-${(i + 1).toString().padStart(3, '0')}`,
22      role: roles[i % roles.length],
23      status: statuses[i % statuses.length],
24      missions: Math.floor(Math.random() * 50),
25      rating: (3 + Math.random() * 2).toFixed(1),
26    });
27  }
28  return members;
29}
30
31const ALL_CREW = generateCrewMembers(200);
32
33// ========================
34// ZOPTYMALIZOWANE KOMPONENTY
35// ========================
36
37// React.memo - zapobiega niepotrzebnym re-renderom
38const StatCard = memo(function StatCard({ label, value, color, icon }) {
39  return (
40    <div style={{
41      padding: '16px', borderRadius: '10px',
42      background: `${color}11`, border: `1px solid ${color}33`,
43      textAlign: 'center', minWidth: '120px',
44    }}>
45      <div style={{ fontSize: '24px', marginBottom: '4px' }}>{icon}</div>
46      <div style={{ fontSize: '22px', fontWeight: 'bold', color }}>{value}</div>
47      <div style={{ fontSize: '12px', color: '#778da9', marginTop: '4px' }}>{label}</div>
48    </div>
49  );
50});
51
52// Memoizowany wiersz tabeli
53const CrewRow = memo(function CrewRow({ member, onSelect }) {
54  const statusColor = {
55    'Aktywny': '#4ecdc4',
56    'Na misji': '#ffd700',
57    'Odpoczynek': '#778da9',
58    'Szkolenie': '#00d4ff',
59  }[member.status] || '#778da9';
60
61  return (
62    <div
63      onClick={() => onSelect(member)}
64      style={{
65        display: 'grid', gridTemplateColumns: '1fr 100px 90px 60px 50px',
66        padding: '10px 14px', fontSize: '13px',
67        borderBottom: '1px solid #1a2a3c', cursor: 'pointer',
68        transition: 'background 0.15s',
69      }}
70      onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,212,255,0.05)'}
71      onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
72    >
73      <span style={{ color: '#e0e1dd' }}>{member.name}</span>
74      <span style={{ color: '#778da9' }}>{member.role}</span>
75      <span style={{ color: statusColor }}>{member.status}</span>
76      <span style={{ color: '#c0c8d0', textAlign: 'center' }}>{member.missions}</span>
77      <span style={{ color: '#ffd700', textAlign: 'center' }}>{member.rating}</span>
78    </div>
79  );
80});
81
82// TODO 1: Owiń komponent DetailPanel w React.memo
83// Powinien renderować się tylko wtedy, gdy zmieni się selectedMember
84function DetailPanel({ selectedMember }) {
85  if (!selectedMember) {
86    return (
87      <div style={{
88        padding: '40px', textAlign: 'center', color: '#556677',
89        background: 'rgba(255,255,255,0.02)', borderRadius: '10px',
90      }}>
91        Wybierz członka załogi, aby zobaczyć szczegóły
92      </div>
93    );
94  }
95  return (
96    <div style={{
97      padding: '20px', background: 'rgba(0, 212, 255, 0.04)',
98      borderRadius: '10px', border: '1px solid #1a3a5c',
99    }}>
100      <h3 style={{ color: '#00d4ff', margin: '0 0 12px' }}>{selectedMember.name}</h3>
101      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', fontSize: '13px' }}>
102        <div><span style={{ color: '#778da9' }}>Rola:</span> <span style={{ color: '#e0e1dd' }}>{selectedMember.role}</span></div>
103        <div><span style={{ color: '#778da9' }}>Status:</span> <span style={{ color: '#4ecdc4' }}>{selectedMember.status}</span></div>
104        <div><span style={{ color: '#778da9' }}>Misje:</span> <span style={{ color: '#e0e1dd' }}>{selectedMember.missions}</span></div>
105        <div><span style={{ color: '#778da9' }}>Ocena:</span> <span style={{ color: '#ffd700' }}>{selectedMember.rating}/5.0</span></div>
106      </div>
107    </div>
108  );
109}
110
111// ========================
112// GŁÓWNY DASHBOARD
113// ========================
114export default function App() {
115  const [searchQuery, setSearchQuery] = useState('');
116  const [statusFilter, setStatusFilter] = useState('all');
117  const [selectedMember, setSelectedMember] = useState(null);
118  const [isPending, startTransition] = useTransition();
119
120  // useDeferredValue - opóźnione wyszukiwanie
121  const deferredSearch = useDeferredValue(searchQuery);
122
123  // useMemo - zapamiętanie przefiltrowanej listy
124  const filteredCrew = useMemo(() => {
125    return ALL_CREW.filter(member => {
126      const matchesSearch = member.name.toLowerCase().includes(deferredSearch.toLowerCase()) ||
127                            member.role.toLowerCase().includes(deferredSearch.toLowerCase());
128      const matchesStatus = statusFilter === 'all' || member.status === statusFilter;
129      return matchesSearch && matchesStatus;
130    });
131  }, [deferredSearch, statusFilter]);
132
133  // useMemo - obliczenie statystyk
134  const stats = useMemo(() => ({
135    total: ALL_CREW.length,
136    active: ALL_CREW.filter(m => m.status === 'Aktywny').length,
137    onMission: ALL_CREW.filter(m => m.status === 'Na misji').length,
138    avgRating: (ALL_CREW.reduce((sum, m) => sum + parseFloat(m.rating), 0) / ALL_CREW.length).toFixed(1),
139  }), []);
140
141  // useCallback - stabilna referencja funkcji dla memo komponentów
142  const handleSelect = useCallback((member) => {
143    setSelectedMember(member);
144  }, []);
145
146  // TODO 2: Użyj useCallback dla handleStatusChange
147  // żeby zapobiec ponownym renderom komponentów filtrujących
148  const handleStatusChange = (status) => {
149    startTransition(() => {
150      setStatusFilter(status);
151    });
152  };
153
154  // TODO 3: Użyj useMemo, aby obliczyć topPerformers
155  // (5 najlepszych członków według oceny z przefiltrowanej listy)
156  // sort zmienia tablicę w miejscu, więc sortujemy kopię: inaczej tabela też ułożyłaby się według oceny
157  const topPerformers = [...filteredCrew]
158    .sort((a, b) => parseFloat(b.rating) - parseFloat(a.rating))
159    .slice(0, 5);
160
161  const STATUSES = ['all', 'Aktywny', 'Na misji', 'Odpoczynek', 'Szkolenie'];
162
163  return (
164    <div style={{
165      minHeight: '100vh',
166      background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
167      padding: '20px', fontFamily: 'monospace', color: '#e0e1dd',
168    }}>
169      <h1 style={{ color: '#00d4ff', textAlign: 'center', margin: '0 0 4px' }}>
170        Centrum Dowodzenia Floty
171      </h1>
172      <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '20px', fontSize: '13px' }}>
173        Dashboard z React.memo | useMemo | useCallback | useTransition | useDeferredValue
174      </p>
175
176      {/* Statystyki */}
177      <div style={{
178        display: 'flex', gap: '12px', justifyContent: 'center',
179        marginBottom: '20px', flexWrap: 'wrap',
180      }}>
181        <StatCard icon="◆" label="Cała zaloga" value={stats.total} color="#00d4ff" />
182        <StatCard icon="OK" label="Aktywni" value={stats.active} color="#4ecdc4" />
183        <StatCard icon="▲" label="Na misji" value={stats.onMission} color="#ffd700" />
184        <StatCard icon="◆" label="Średnia ocena" value={stats.avgRating} color="#ff6b4a" />
185      </div>
186
187      <div style={{ maxWidth: '800px', margin: '0 auto' }}>
188        {/* Wyszukiwanie i filtry */}
189        <div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
190          <input
191            value={searchQuery}
192            onChange={e => setSearchQuery(e.target.value)}
193            placeholder="Szukaj członka załogi..."
194            style={{
195              flex: 1, minWidth: '200px', padding: '10px 14px',
196              background: '#0d1b2a', border: '1px solid #1a3a5c',
197              borderRadius: '8px', color: '#e0e1dd', fontSize: '14px',
198            }}
199          />
200          {STATUSES.map(status => (
201            <button
202              key={status}
203              onClick={() => handleStatusChange(status)}
204              style={{
205                padding: '8px 14px',
206                background: statusFilter === status ? '#1a3a5c' : 'transparent',
207                border: `1px solid ${statusFilter === status ? '#00d4ff' : '#1a3a5c'}`,
208                borderRadius: '6px', fontSize: '12px',
209                color: statusFilter === status ? '#00d4ff' : '#778da9',
210                cursor: 'pointer',
211              }}
212            >
213              {status === 'all' ? 'Wszyscy' : status}
214            </button>
215          ))}
216        </div>
217
218        {isPending && (
219          <div style={{ color: '#00d4ff', fontSize: '12px', marginBottom: '8px' }}>
220            Filtrowanie...
221          </div>
222        )}
223
224        <div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: '16px' }}>
225          {/* Tabela załogi */}
226          <div>
227            <div style={{
228              display: 'grid', gridTemplateColumns: '1fr 100px 90px 60px 50px',
229              padding: '8px 14px', fontSize: '11px', color: '#556677',
230              borderBottom: '2px solid #1a3a5c', textTransform: 'uppercase',
231            }}>
232              <span>Nazwa</span><span>Rola</span><span>Status</span>
233              <span style={{ textAlign: 'center' }}>Misje</span>
234              <span style={{ textAlign: 'center' }}>Ocena</span>
235            </div>
236            <div style={{ maxHeight: '350px', overflow: 'auto' }}>
237              {filteredCrew.slice(0, 50).map(member => (
238                <CrewRow
239                  key={member.id}
240                  member={member}
241                  onSelect={handleSelect}
242                />
243              ))}
244            </div>
245            <div style={{ padding: '8px 14px', fontSize: '12px', color: '#556677' }}>
246              Wyświetlono {Math.min(50, filteredCrew.length)} z {filteredCrew.length} wyników
247            </div>
248          </div>
249
250          {/* Panel boczny */}
251          <div>
252            <DetailPanel selectedMember={selectedMember} />
253
254            <div style={{ marginTop: '16px' }}>
255              <h4 style={{ color: '#ffd700', margin: '0 0 8px', fontSize: '13px' }}>
256                Top 5 - najwyższa ocena
257              </h4>
258              {topPerformers.map((m, i) => (
259                <div key={m.id} style={{
260                  display: 'flex', justifyContent: 'space-between',
261                  padding: '6px 10px', fontSize: '12px',
262                  background: i === 0 ? 'rgba(255, 215, 0, 0.06)' : 'transparent',
263                  borderRadius: '4px',
264                }}>
265                  <span style={{ color: '#c0c8d0' }}>
266                    {i + 1}. {m.name}
267                  </span>
268                  <span style={{ color: '#ffd700' }}>{m.rating}</span>
269                </div>
270              ))}
271            </div>
272          </div>
273        </div>
274      </div>
275    </div>
276  );
277}

Widzisz błąd w tej lekcji?

Przydatne artykuły