Kurs JavaScript i React · Moduł 8: Zaawansowane wzorce React

Projekt główny: Budowanie aplikacji produkcyjnej

6 min czytania
W tej lekcji7

Znasz już wiele osobnych narzędzi ale prawdziwa misja zaczyna się wtedy, gdy trzeba je złożyć w jeden statek. Każdy wzorzec z osobna wygląda prosto. Kłopot pojawia się w dużej aplikacji: gdzie trzymać logikę, jak dzielić się nią między ekranami i jak sprawdzić, że po zmianie nic się nie zepsuło. Ta lekcja odpowiada właśnie na te pytania.

Nadszedł moment kulminacyjny naszej kosmicznej wyprawy. Przez cały ten moduł poznawaliśmy zaawansowane wzorce i najlepsze praktyki React - od custom hooków, przez Higher-Order Components i render props, aż po compound components, testowanie i deployment. Teraz pora połączyć te wszystkie elementy w jeden spójny projekt: Centrum Dowodzenia Misją Kosmiczną.

Architektura projektu

Budując aplikację produkcyjną, kluczowe jest przemyślenie jej architektury. Nasz projekt łączy wzorce, które poznaliśmy w tym module. Zacznijmy od mapy pokładów, czyli struktury folderów:

1// Struktura projektu
2// src/
3//   hooks/          - Custom hooki (useMission, useFuel, useCrewStatus)
4//   components/     - Komponenty wielokrotnego użytku
5//   hoc/            - Higher-Order Components (withAuth, withLogging)
6//   context/        - Konteksty React (MissionContext, ThemeContext)
7//   utils/          - Funkcje pomocnicze i walidacja
8//   App.jsx         - Główny komponent aplikacji

Każdy folder to osobny pokład statku z jasno określonym zadaniem. Sam React nie narzuca takiego podziału, to nasza konwencja. Ważne, żeby cały zespół trzymał się jednej.

Custom Hooki - serce logiki biznesowej

Custom hooki pozwalają wydzielić logikę z komponentów i uczynić ją wielokrotnie używalną. W naszym projekcie każdy aspekt misji kosmicznej ma swój dedykowany hook. Hook useMission przechowuje dane misji i jej status w stanie oraz udostępnia funkcje zmieniające status:

1// Custom hook do zarządzania misją
2function useMission(initialMission) {
3  const [mission, setMission] = useState(initialMission);
4  const [status, setStatus] = useState('planning');
5
6  const launch = () => {
7    if (mission.fuel >= mission.requiredFuel) {
8      setStatus('in-progress');
9    }
10  };
11
12  const complete = () => setStatus('completed');
13  const abort = () => setStatus('aborted');
14
15  return { mission, status, launch, complete, abort };
16}

Komponent, który użyje useMission, dostaje gotowe launch, complete i abort, nie wiedząc nic o useState w środku. Start misji zmienia status tylko wtedy, gdy paliwa jest co najmniej tyle, ile wymaga misja. W przeciwnym razie status zostaje planning.

Higher-Order Components i Render Props

HOC pozwalają na dodawanie funkcjonalności do komponentów bez modyfikacji ich kodu źródłowego. Higher-Order Component to funkcja, która przyjmuje komponent i zwraca nowy komponent z dodatkowymi propsami. Tutaj withLogging dokleja funkcję logAction:

1// HOC - dodaje logowanie akcji
2function withLogging(WrappedComponent) {
3  return function LoggedComponent(props) {
4    const logAction = (action) => {
5      console.log(`[LOG] ${new Date().toISOString()}: ${action}`);
6    };
7    return <WrappedComponent {...props} logAction={logAction} />;
8  };
9}

Opakowany komponent działa jak wcześniej i dostaje tylko jeden nowy prop. Jego własny kod się nie zmienia.

Render props dają elastyczność w renderowaniu. Komponent z render prop trzyma dane u siebie, a to, co pokazać, oddaje funkcji przekazanej w propsie render:

1// Render Props - udostępnia dane o paliwie
2function FuelProvider({ render }) {
3  const [fuel, setFuel] = useState(100);
4  const consume = (amount) => setFuel(prev => Math.max(0, prev - amount));
5  const refuel = (amount) => setFuel(prev => Math.min(100, prev + amount));
6  return render({ fuel, consume, refuel });
7}

FuelProvider pilnuje, żeby paliwo nie spadło poniżej 0 ani nie przekroczyło 100, ale nie decyduje o wyglądzie. Od wprowadzenia hooków w nowym kodzie większość zadań HOC i render props przejęły custom hooki. Oba wzorce warto jednak znać, bo spotkasz je w starszych bibliotekach i projektach.

Compound Components

Wzorzec compound components pozwala tworzyć komponenty, które działają razem jako spójny system. Najpierw rodzic, który trzyma wspólny stan rozwinięcia i przekazuje go dzieciom:

1// Compound Component - Panel Misji
2function MissionPanel({ children }) {
3  const [isExpanded, setIsExpanded] = useState(false);
4  return (
5    <div className="mission-panel">
6      {React.Children.map(children, child =>
7        React.isValidElement(child)
8          ? React.cloneElement(child, { isExpanded, setIsExpanded })
9          : child
10      )}
11    </div>
12  );
13}

React.Children.map przechodzi po dzieciach, a React.cloneElement dokłada każdemu propsy isExpanded i setIsExpanded. Teraz części składowe panelu, przypięte jako właściwości MissionPanel:

1MissionPanel.Header = function Header({ isExpanded, setIsExpanded, children }) {
2  return (
3    <div onClick={() => setIsExpanded(!isExpanded)} style={{ cursor: 'pointer' }}>
4      {children} {isExpanded ? '▲' : '▼'}
5    </div>
6  );
7};
8
9MissionPanel.Body = function Body({ isExpanded, children }) {
10  return isExpanded ? <div>{children}</div> : null;
11};

Nagłówek przełącza stan, a treść pokazuje się tylko po rozwinięciu. Uwaga praktyczna: react.dev zalicza cloneElement do rzadko używanych API, bo łatwo je zepsuć, na przykład opakowując dziecko w dodatkowy div. W nowym kodzie polecam przekazywać wspólny stan przez kontekst (createContext i useContext), a cloneElement traktować jako wersję historyczną.

Testowanie - klucz do niezawodności

Kod produkcyjny musi być przetestowany. Każdy custom hook, komponent i funkcja pomocnicza powinny mieć odpowiednie testy. Hooki testujemy funkcją renderHook, a wywołania zmieniające stan opakowujemy w act. Obie funkcje pochodzą z pakietu @testing-library/react (dawny osobny pakiet @testing-library/react-hooks jest przestarzały):

1// Przykładowy test custom hooka
2// describe('useMission', () => {
3//   it('powinien uruchomić misję, gdy jest wystarczająco paliwa', () => {
4//     const { result } = renderHook(() =>
5//       useMission({ fuel: 100, requiredFuel: 50 })
6//     );
7//     act(() => result.current.launch());
8//     expect(result.current.status).toBe('in-progress');
9//   });
10//
11//   it('nie powinien uruchomić misji bez paliwa', () => {
12//     const { result } = renderHook(() =>
13//       useMission({ fuel: 20, requiredFuel: 50 })
14//     );
15//     act(() => result.current.launch());
16//     expect(result.current.status).toBe('planning');
17//   });
18// });

Pierwszy test sprawdza, że przy 100 jednostkach paliwa misja startuje, drugi, że przy 20 status zostaje planning. Uruchomiliśmy oba scenariusze i dają dokładnie te wyniki. Kod hooka nie musiał się zmienić, żeby dało się go przetestować.

Deployment i optymalizacja

Przed wdrożeniem aplikacji na produkcję upewnij się, że:

  1. Optymalizacja wydajności - użyj React.memo, useMemo i useCallback tam, gdzie to potrzebne
  2. Code splitting - podziel aplikację na mniejsze chunki za pomocą lazy() i Suspense
  3. Zmienne środowiskowe - przechowuj konfigurację w plikach .env
  4. Error boundaries - dodaj obsługę błędów na poziomie komponentów
  5. Build produkcyjny - uruchom npm run build i przetestuj wynik

Trzy doprecyzowania do tej listy. W projekcie Vite do kodu przeglądarki trafiają tylko zmienne z prefiksem VITE_, odczytywane przez import.meta.env, więc nigdy nie trzymaj w nich sekretów. Error boundary nadal pisze się jako komponent klasowy albo korzysta z biblioteki react-error-boundary. A wynik buildu obejrzysz lokalnie poleceniem npm run preview.

Podsumowanie modułu

W tym module poznałeś zaawansowane wzorce React, które są fundamentem profesjonalnego tworzenia aplikacji:

  • Custom Hooki - wydzielanie i współdzielenie logiki między komponentami
  • Higher-Order Components - dodawanie funkcjonalności przez kompozycję
  • Render Props - elastyczne renderowanie z przekazywaniem danych
  • Compound Components - tworzenie spójnych systemów komponentów
  • Testowanie - zapewnienie niezawodności kodu
  • Deployment - wdrażanie aplikacji na produkcję

Moja rada na koniec: zaczynaj od custom hooka, po HOC i render props sięgaj tylko wtedy, gdy wymaga tego biblioteka, i pisz test dla każdej logiki, która decyduje o losie misji. Po tej lekcji czeka cię projekt dla mentora, w którym złożysz wszystkie te wzorce w jedno laboratorium.

Te wzorce to Twój zestaw narzędzi kosmicznego inżyniera - dzięki nim możesz budować aplikacje React na najwyższym poziomie!

Kod do tej lekcji: App.jsx
1import React, { useState, useCallback, memo } from 'react';
2
3// =============================================
4// CENTRUM DOWODZENIA MISJA KOSMICZNA
5// Projekt laczacy: custom hooks, HOC, render props,
6// compound components i najlepsze praktyki React
7// =============================================
8
9// --- Custom Hook: useMission ---
10function useMission(initialName = '') {
11  const [mission, setMission] = useState({
12    name: initialName,
13    status: 'planning',
14    fuel: 100,
15    crew: 0,
16  });
17
18  const launch = useCallback(() => {
19    setMission(prev => {
20      if (prev.fuel < 30) return prev;
21      return { ...prev, status: 'in-progress' };
22    });
23  }, []);
24
25  const complete = useCallback(() => {
26    setMission(prev => ({ ...prev, status: 'completed' }));
27  }, []);
28
29  const abort = useCallback(() => {
30    setMission(prev => ({ ...prev, status: 'aborted' }));
31  }, []);
32
33  const updateFuel = useCallback((amount) => {
34    setMission(prev => ({
35      ...prev,
36      fuel: Math.max(0, Math.min(100, prev.fuel + amount)),
37    }));
38  }, []);
39
40  const updateCrew = useCallback((count) => {
41    setMission(prev => ({ ...prev, crew: Math.max(0, count) }));
42  }, []);
43
44  const rename = useCallback((name) => {
45    setMission(prev => ({ ...prev, name }));
46  }, []);
47
48  return { mission, launch, complete, abort, updateFuel, updateCrew, rename };
49}
50
51// --- HOC: withStatusColor ---
52function withStatusColor(WrappedComponent) {
53  return function StatusColorComponent(props) {
54    const colorMap = {
55      planning: '#00d4ff',
56      'in-progress': '#ffd700',
57      completed: '#4CAF50',
58      aborted: '#f44336',
59    };
60    const statusColor = colorMap[props.status] || '#e0e1dd';
61    return <WrappedComponent {...props} statusColor={statusColor} />;
62  };
63}
64
65// --- Compound Component: MissionPanel ---
66function MissionPanel({ children }) {
67  const [isExpanded, setIsExpanded] = useState(true);
68  return (
69    <div style={{
70      background: '#1b2838', borderRadius: '12px',
71      padding: '16px', marginBottom: '12px',
72      border: '1px solid #2a3f5f',
73    }}>
74      {React.Children.map(children, child =>
75        React.isValidElement(child)
76          ? React.cloneElement(child, { isExpanded, setIsExpanded })
77          : child
78      )}
79    </div>
80  );
81}
82
83MissionPanel.Header = function Header({ isExpanded, setIsExpanded, children }) {
84  return (
85    <div
86      onClick={() => setIsExpanded(!isExpanded)}
87      style={{
88        cursor: 'pointer', display: 'flex',
89        justifyContent: 'space-between', alignItems: 'center',
90        color: '#ffd700', fontSize: '16px', fontWeight: 'bold',
91      }}
92    >
93      {children}
94      <span>{isExpanded ? '▲' : '▼'}</span>
95    </div>
96  );
97};
98
99MissionPanel.Body = function Body({ isExpanded, children }) {
100  if (!isExpanded) return null;
101  return <div style={{ marginTop: '12px' }}>{children}</div>;
102};
103
104// --- Render Props: FuelGauge ---
105function FuelGauge({ fuel, render }) {
106  const level = fuel > 60 ? 'high' : fuel > 30 ? 'medium' : 'low';
107  const color = level === 'high' ? '#4CAF50' : level === 'medium' ? '#ffd700' : '#f44336';
108  return render({ fuel, level, color });
109}
110
111// --- Komponent z HOC ---
112const StatusBadge = withStatusColor(memo(function StatusBadge({ status, statusColor }) {
113  return (
114    <span style={{
115      display: 'inline-block', padding: '4px 12px',
116      borderRadius: '12px', background: statusColor,
117      color: '#0d1b2a', fontWeight: 'bold', fontSize: '13px',
118    }}>
119      {status.toUpperCase()}
120    </span>
121  );
122}));
123
124// --- Glowny komponent App ---
125export default function App() {
126  const { mission, launch, complete, abort, updateFuel, updateCrew, rename } = useMission('Apollo X');
127
128  return (
129    <div style={{
130      background: '#0d1b2a', minHeight: '100vh',
131      padding: '20px', fontFamily: 'Arial', color: '#e0e1dd',
132    }}>
133      <h1 style={{ color: '#ffd700', textAlign: 'center', marginBottom: '6px' }}>
134        Centrum Dowodzenia
135      </h1>
136      <p style={{ textAlign: 'center', color: '#778da9', marginBottom: '20px', fontSize: '14px' }}>
137        Projekt laczacy custom hooks, HOC, render props i compound components
138      </p>
139
140      {/* Panel misji - Compound Component */}
141      <MissionPanel>
142        <MissionPanel.Header>Dane Misji</MissionPanel.Header>
143        <MissionPanel.Body>
144          <div style={{ marginBottom: '10px' }}>
145            <label style={{ color: '#778da9', fontSize: '13px' }}>Nazwa misji:</label>
146            <input
147              value={mission.name}
148              onChange={e => rename(e.target.value)}
149              style={{
150                width: '100%', padding: '6px 10px', marginTop: '4px',
151                background: '#0d1b2a', border: '1px solid #2a3f5f',
152                borderRadius: '6px', color: '#e0e1dd', fontSize: '14px',
153              }}
154            />
155          </div>
156          <div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px' }}>
157            <span style={{ color: '#778da9', fontSize: '13px' }}>Status:</span>
158            <StatusBadge status={mission.status} />
159          </div>
160          <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
161            <button onClick={launch} style={btnStyle('#4CAF50')}>Start</button>
162            <button onClick={complete} style={btnStyle('#2196F3')}>Zakoncz</button>
163            <button onClick={abort} style={btnStyle('#f44336')}>Przerwij</button>
164          </div>
165        </MissionPanel.Body>
166      </MissionPanel>
167
168      {/* Paliwo - Render Props */}
169      <MissionPanel>
170        <MissionPanel.Header>Paliwo</MissionPanel.Header>
171        <MissionPanel.Body>
172          <FuelGauge fuel={mission.fuel} render={({ fuel, color }) => (
173            <div>
174              <div style={{
175                width: '100%', height: '20px', background: '#0d1b2a',
176                borderRadius: '10px', overflow: 'hidden', marginBottom: '8px',
177              }}>
178                <div style={{
179                  width: fuel + '%', height: '100%',
180                  background: color, borderRadius: '10px',
181                  transition: 'all 0.3s',
182                }} />
183              </div>
184              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
185                <span style={{ color, fontWeight: 'bold' }}>{fuel}%</span>
186                <div style={{ display: 'flex', gap: '6px' }}>
187                  <button onClick={() => updateFuel(-10)} style={btnStyle('#f44336')}>-10</button>
188                  <button onClick={() => updateFuel(10)} style={btnStyle('#4CAF50')}>+10</button>
189                </div>
190              </div>
191            </div>
192          )} />
193        </MissionPanel.Body>
194      </MissionPanel>
195
196      {/* Zaloga */}
197      <MissionPanel>
198        <MissionPanel.Header>Zaloga ({mission.crew} czlonkow)</MissionPanel.Header>
199        <MissionPanel.Body>
200          <div style={{ display: 'flex', gap: '6px' }}>
201            <button onClick={() => updateCrew(mission.crew + 1)} style={btnStyle('#4CAF50')}>
202              + Dodaj
203            </button>
204            <button onClick={() => updateCrew(mission.crew - 1)} style={btnStyle('#f44336')}>
205              - Usun
206            </button>
207          </div>
208        </MissionPanel.Body>
209      </MissionPanel>
210    </div>
211  );
212}
213
214function btnStyle(bg) {
215  return {
216    padding: '6px 14px', background: bg, color: 'white',
217    border: 'none', borderRadius: '6px', cursor: 'pointer',
218    fontWeight: 'bold', fontSize: '13px',
219  };
220}

Widzisz błąd w tej lekcji?

Przydatne artykuły