Kurs JavaScript i React · Moduł 8: Zaawansowane wzorce React
Projekt główny: Budowanie aplikacji produkcyjnej
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 aplikacjiKaż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:
- Optymalizacja wydajności - użyj
React.memo,useMemoiuseCallbacktam, gdzie to potrzebne - Code splitting - podziel aplikację na mniejsze chunki za pomocą
lazy()iSuspense - Zmienne środowiskowe - przechowuj konfigurację w plikach
.env - Error boundaries - dodaj obsługę błędów na poziomie komponentów
- Build produkcyjny - uruchom
npm run buildi 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?