Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React
Projekt: Zaawansowana aplikacja React 18+
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
Panel z aktualizacjami na żywo
- Wykorzystanie useTransition i useDeferredValue
- Optymistyczne aktualizacje z useOptimistic
- Streaming zawartości z Suspense
Zaawansowane formularze
- useFormStatus i useActionState
- Server Actions
- Walidacja w czasie rzeczywistym
System powiadomień
- useId dla dostępności
- Concurrent rendering
- Progressive enhancement
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 typesFolder 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]);
64Funkcja 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}
61safeParse 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:
- Concurrent Features: useTransition, useDeferredValue dla responsywnego UI
- Obsługa formularzy: useActionState, useFormStatus z Server Actions
- Dostępność: useId dla unikalnych identyfikatorów
- Optymistyczne aktualizacje: useOptimistic dla natychmiastowej informacji zwrotnej
- Server Components: Renderowanie na serwerze z równoległym pobieraniem danych
- Wzorce Suspense: zagnieżdżone stany ładowania i strumieniowanie
- Wydajność: monitorowanie i optymalizacja
- Obsługa błędów: osobne Error Boundaries dla poszczególnych sekcji
- Bezpieczeństwo typów: pełne wykorzystanie TypeScriptu
Następne kroki do rozszerzenia:
- Dodaj synchronizację na żywo przez WebSockety
- Dodaj pracę offline z Service Workerem
- Dodaj zaawansowane filtry i sortowanie
- Stwórz system uprawnień użytkowników
- Zintegruj z systemem powiadomień push
- Dodaj eksport danych do różnych formatów
- Dodaj tryb ciemny z CSS-in-JS
- 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?