Kurs JavaScript i React · Moduł 4: Context API i useReducer
Wzorce zarządzania stanem
W tej lekcji7
Na Stacji Jupiter zarządzasz wieloma systemami jednocześnie - nawigacją, paliwem, załogą, komunikacją. Każdy system potrzebuje danych i musi je udostępniać innym modułom. W React zarządzanie stanem to jedno z najważniejszych wyzwań architektonicznych. Wybór odpowiedniego wzorca determinuje, czy Twoja aplikacja będzie łatwa w utrzymaniu i skalowalna, czy stanie się chaotycznym labiryntem zależności.
Problem: Prop drilling
Zanim poznałeś Context API, jedynym sposobem na przekazywanie danych w dół drzewa komponentów było prop drilling - ręczne przekazywanie propsów przez każdy poziom. To jak przesyłanie wiadomości przez łańcuch członków załogi, gdzie każdy musi powtórzyć komunikat dalej, choć sam go nie potrzebuje.
1// Prop drilling - 4 poziomy przekazywania!
2function SpaceStation({ missionData }) {
3 return <Deck missionData={missionData} />;
4}
5
6function Deck({ missionData }) {
7 // Deck nie używa missionData - tylko przekazuje dalej
8 return <Room missionData={missionData} />;
9}
10
11function Room({ missionData }) {
12 // Room też nie używa - przekazuje dalej
13 return <Terminal missionData={missionData} />;
14}
15
16function Terminal({ missionData }) {
17 // Dopiero tutaj dane są potrzebne!
18 return <p>Misja: {missionData.name}</p>;
19}Przy 4 poziomach to jeszcze znośne, ale przy 8-10 poziomach (co w dużych aplikacjach jest normą) kod staje się nieczytelny, a każda zmiana w strukturze danych wymaga modyfikacji wielu pośrednich komponentów.
State colocation - trzymaj stan blisko miejsca użycia
Zanim sięgniesz po globalne rozwiązania, rozważ state colocation - zasadę trzymania stanu jak najbliżej komponentów, które go potrzebują. Nie każda dana musi być globalna. To jak na statku kosmicznym: temperatura kabiny pilota nie musi być znana w maszynowni.
1// ZŁE: Globalny stan dla lokalnych danych
2function App() {
3 const [searchQuery, setSearchQuery] = useState('');
4 const [isModalOpen, setIsModalOpen] = useState(false);
5 const [formData, setFormData] = useState({});
6 // ... 20 innych stanów
7
8 return (
9 <GlobalContext.Provider value={/* wszystko */}>
10 <Dashboard />
11 </GlobalContext.Provider>
12 );
13}
14
15// DOBRE: Stan blisko miejsca użycia
16function App() {
17 // Tylko naprawdę globalne dane
18 const [user, setUser] = useState(null);
19 const [theme, setTheme] = useState('dark');
20
21 return (
22 <AppContext.Provider value={{ user, theme }}>
23 <Dashboard />
24 </AppContext.Provider>
25 );
26}
27
28function SearchPanel() {
29 // Lokalny stan - tylko ten komponent go potrzebuje
30 const [searchQuery, setSearchQuery] = useState('');
31 return <input value={searchQuery} onChange={e => setSearchQuery(e.target.value)} />;
32}Reguła kciuka: jeśli stan jest używany tylko przez jeden komponent lub jego bezpośrednie dzieci - trzymaj go lokalnie. Przenoś do góry (lifting state up) lub do kontekstu dopiero, gdy potrzebują go odległe gałęzie drzewa komponentów.
Context API - wbudowane rozwiązanie
Context API rozwiązuje prop drilling bez dodatkowych bibliotek. Poznałeś go wcześniej w tym module. Jego główne zalety i ograniczenia:
Zalety:
- Wbudowany w React - zero dodatkowych zależności
- Prosty API:
createContext,Provider,useContext - Doskonały dla danych zmieniających się rzadko (theme, auth, locale)
Ograniczenia:
- Każda zmiana wartości re-renderuje WSZYSTKICH konsumentów
- Brak wbudowanego cache'owania danych z API
- Przy złożonym stanie wymaga łączenia z useReducer
- Trudna optymalizacja wydajności w dużych aplikacjach
1// Context API - dobry wybór dla konfiguracji statku
2const ShipConfigContext = createContext();
3
4function ShipConfigProvider({ children }) {
5 const [config, setConfig] = useState({
6 theme: 'dark-nebula',
7 language: 'pl',
8 soundEnabled: true,
9 });
10
11 return (
12 <ShipConfigContext.Provider value={{ config, setConfig }}>
13 {children}
14 </ShipConfigContext.Provider>
15 );
16}Redux - centrum dowodzenia dla dużych flot
Redux to sprawdzony wzorzec zarządzania stanem, inspirowany architekturą Flux. Działa jak centralne centrum dowodzenia floty - jeden store (magazyn) przechowuje cały stan aplikacji, a zmiany odbywają się wyłącznie przez dispatch akcji. Nowoczesny Redux piszemy z użyciem Redux Toolkit - createSlice definiuje stan i reducery, a configureStore buduje magazyn:
1// Redux Toolkit - nowoczesny standard Redux
2import { createSlice, configureStore } from '@reduxjs/toolkit';
3
4// Slice = fragment stanu + reducery + akcje w jednym miejscu
5const missionSlice = createSlice({
6 name: 'mission',
7 initialState: {
8 crew: [],
9 fuel: 100,
10 destination: null,
11 status: 'docked',
12 },
13 reducers: {
14 // Immer pozwala "mutować" stan - RTK zamienia to na bezpieczną aktualizację
15 addCrewMember: (state, action) => {
16 state.crew.push(action.payload);
17 },
18 setDestination: (state, action) => {
19 state.destination = action.payload;
20 },
21 burnFuel: (state, action) => {
22 state.fuel = Math.max(0, state.fuel - action.payload);
23 },
24 launch: (state) => {
25 state.status = 'in-flight';
26 },
27 },
28});
29
30// createSlice automatycznie generuje kreatory akcji
31export const { addCrewMember, setDestination, burnFuel, launch } = missionSlice.actions;
32
33// configureStore buduje magazyn (z DevTools i middleware w komplecie)
34const store = configureStore({
35 reducer: {
36 mission: missionSlice.reducer,
37 },
38});
39
40// Użycie w komponencie:
41function MissionControl() {
42 const dispatch = useDispatch();
43 const { crew, fuel, status } = useSelector(state => state.mission);
44
45 return (
46 <div>
47 <p>Status: {status} | Paliwo: {fuel}% | Załoga: {crew.length}</p>
48 <button onClick={() => dispatch(launch())}>Start!</button>
49 <button onClick={() => dispatch(burnFuel(10))}>Spal paliwo</button>
50 </div>
51 );
52}Kiedy Redux?
- Aplikacja z wieloma developerami (jasne reguły, przewidywalny przepływ danych)
- Złożony stan z wieloma powiązanymi akcjami
- Potrzebujesz DevTools z time-travel debugging
- Middleware do obsługi side-effects (thunki, saga)
Kiedy NIE Redux?
- Prosta aplikacja z kilkoma stanami
- Mały zespół lub solo developer
- Głównie dane z API (lepiej React Query)
Zustand - lekki silnik manewrowy
Zustand to minimalistyczna biblioteka do zarządzania stanem. Jeśli Redux to potężny silnik główny, Zustand to zwinny silnik manewrowy - robi to samo, ale z mniejszym narzutem. Bez providerów, bez boilerplate'u, bez problemu re-renderów Context API.
1// Zustand - prosty store bez providerów
2// import { create } from 'zustand';
3
4// Tworzenie store - to wszystko co potrzebujesz!
5// const useShipStore = create((set) => ({
6// fuel: 100,
7// position: { x: 0, y: 0 },
8// crew: 5,
9//
10// burnFuel: (amount) => set((state) => ({
11// fuel: Math.max(0, state.fuel - amount)
12// })),
13//
14// moveTo: (x, y) => set({ position: { x, y } }),
15//
16// addCrew: () => set((state) => ({
17// crew: state.crew + 1
18// })),
19// }));
20
21// Użycie w komponencie - bez Providera!
22function FuelGauge() {
23 // Komponent re-renderuje się TYLKO gdy zmieni się fuel
24 // const fuel = useShipStore((state) => state.fuel);
25 // return <div>Paliwo: {fuel}%</div>;
26}
27
28function NavigationPanel() {
29 // Odczytuje TYLKO position - zmiana fuel nie powoduje re-renderu
30 // const position = useShipStore((state) => state.position);
31 // const moveTo = useShipStore((state) => state.moveTo);
32 // return <button onClick={() => moveTo(10, 20)}>Leć!</button>;
33}Zustand automatycznie optymalizuje re-rendery dzięki selektorom. Każdy komponent subskrybuje się tylko na te dane, których faktycznie używa. Zmiana paliwa nie re-renderuje panelu nawigacji - i odwrotnie.
Porównanie wzorców - mapa galaktyki
| Cecha | Context API | Redux Toolkit | Zustand |
|---|---|---|---|
| Rozmiar | 0 KB (wbudowany) | ~11 KB | ~1.5 KB |
| Boilerplate | Średni | Duży (ale RTK pomaga) | Minimalny |
| Re-rendery | Wszyscy konsumenci | Zoptymalizowane (selektory) | Zoptymalizowane (selektory) |
| DevTools | Brak dedykowanych | Rozbudowane (time-travel) | Rozszerzenie Redux DevTools |
| Krzywa nauki | Łatwa | Stroma | Łatwa |
| Najlepsze dla | Theme, auth, locale | Duże aplikacje, duże zespoły | Większość aplikacji |
Zasada wyboru narzędzia
Na Stacji Jupiter każdy system ma swoje przeznaczenie. Podobnie z zarządzaniem stanem:
- Stan lokalny (
useState) - dane potrzebne tylko jednemu komponentowi lub jego bezpośrednim dzieciom - Lifted state (stan podniesiony) - dane współdzielone przez 2-3 bliskie komponenty, przenieś do ich wspólnego rodzica
- Context API - dane globalne zmieniające się rzadko: theme, auth, locale, język
- Zustand - stan globalny w małych/średnich aplikacjach, gdy potrzebujesz wydajności i prostoty
- Redux Toolkit - duże aplikacje z wieloma developerami, złożona logika biznesowa, potrzeba DevTools
Pamiętaj: nie ma jednego najlepszego rozwiązania. Najlepsi kosmiczni inżynierowie dobierają narzędzie do problemu, a nie problem do narzędzia.
Przetestuj porównanie wzorców w edytorze poniżej:
Kod do tej lekcji: App.jsx
1import React, { createContext, useContext, useState, useReducer } from 'react';
2
3// === WZORCE ZARZĄDZANIA STANEM ===
4
5// Demo: Porównanie wzorców zarządzania stanem
6// 1. Context API - wbudowany w React
7const ThemeContext = React.createContext();
8
9function ThemeProvider({ children }) {
10 const [theme, setTheme] = useState('dark-nebula');
11 const toggleTheme = () => setTheme(t => t === 'dark-nebula' ? 'light-star' : 'dark-nebula');
12 return (
13 <ThemeContext.Provider value={{ theme, toggleTheme }}>
14 {children}
15 </ThemeContext.Provider>
16 );
17}
18
19function useTheme() {
20 const ctx = useContext(ThemeContext);
21 if (!ctx) throw new Error('useTheme musi być wewnątrz ThemeProvider');
22 return ctx;
23}
24
25// 2. useReducer - wzorzec Redux-like wbudowany w React
26const missionReducer = (state, action) => {
27 switch (action.type) {
28 case 'ADD_CREW':
29 return { ...state, crew: [...state.crew, action.payload] };
30 case 'SET_DESTINATION':
31 return { ...state, destination: action.payload };
32 case 'BURN_FUEL':
33 return { ...state, fuel: Math.max(0, state.fuel - action.payload) };
34 case 'LAUNCH':
35 return { ...state, status: 'in-flight' };
36 case 'DOCK':
37 return { ...state, status: 'docked', fuel: 100 };
38 default:
39 return state;
40 }
41};
42
43const initialMission = {
44 crew: ['Kapitan Ra', 'Nawigator Nova'],
45 fuel: 100,
46 destination: null,
47 status: 'docked',
48};
49
50// === PANELE DEMONSTRACYJNE ===
51
52function ContextDemo() {
53 const { theme, toggleTheme } = useTheme();
54 const isDark = theme === 'dark-nebula';
55 return (
56 <div style={{ ...styles.panel, background: isDark ? '#1a2035' : '#e8eaf6' }}>
57 <h3 style={{ color: '#4fc3f7' }}>Context API</h3>
58 <p style={{ color: isDark ? '#ccc' : '#333', fontSize: 13 }}>
59 Motyw: <strong>{theme}</strong>
60 </p>
61 <p style={{ color: isDark ? '#888' : '#666', fontSize: 11 }}>
62 Idealny dla: theme, auth, locale
63 </p>
64 <button style={styles.btn} onClick={toggleTheme}>Zmień motyw</button>
65 </div>
66 );
67}
68
69function ReducerDemo() {
70 const [state, dispatch] = useReducer(missionReducer, initialMission);
71 const destinations = ['Mars', 'Europa', 'Tytan', 'Kepler-442b'];
72
73 return (
74 <div style={styles.panel}>
75 <h3 style={{ color: '#ffd700' }}>useReducer (Redux-like)</h3>
76 <div style={styles.stats}>
77 <div>Status: <span style={{ color: state.status === 'in-flight' ? '#69f0ae' : '#ffd700' }}>{state.status}</span></div>
78 <div>Paliwo: <span style={{ color: state.fuel < 30 ? '#ff5252' : '#69f0ae' }}>{state.fuel}%</span></div>
79 <div>Cel: <span style={{ color: '#4fc3f7' }}>{state.destination || 'brak'}</span></div>
80 <div>Załoga: {state.crew.join(', ')}</div>
81 </div>
82 <div style={styles.btnRow}>
83 {state.status === 'docked' ? (
84 <>
85 <button style={styles.btn} onClick={() => {
86 const dest = destinations[Math.floor(Math.random() * destinations.length)];
87 dispatch({ type: 'SET_DESTINATION', payload: dest });
88 }}>Wybierz cel</button>
89 {state.destination && (
90 <button style={{ ...styles.btn, background: 'rgba(105,240,174,0.2)', borderColor: '#69f0ae' }}
91 onClick={() => dispatch({ type: 'LAUNCH' })}>
92 Start!
93 </button>
94 )}
95 </>
96 ) : (
97 <>
98 <button style={styles.btn} onClick={() => dispatch({ type: 'BURN_FUEL', payload: 15 })}>
99 Silniki (-15)
100 </button>
101 <button style={{ ...styles.btn, background: 'rgba(255,215,0,0.2)' }}
102 onClick={() => dispatch({ type: 'DOCK' })}>
103 Dokuj
104 </button>
105 </>
106 )}
107 </div>
108 </div>
109 );
110}
111
112function ColocationDemo() {
113 // Stan lokalny - nie potrzebuje Context ani Redux
114 const [localSearch, setLocalSearch] = useState('');
115 const [results, setResults] = useState([]);
116
117 const planets = ['Mars', 'Jupiter', 'Saturn', 'Neptune', 'Venus', 'Mercury', 'Uranus', 'Pluto'];
118
119 const handleSearch = (query) => {
120 setLocalSearch(query);
121 if (query.length > 0) {
122 setResults(planets.filter(p => p.toLowerCase().includes(query.toLowerCase())));
123 } else {
124 setResults([]);
125 }
126 };
127
128 return (
129 <div style={styles.panel}>
130 <h3 style={{ color: '#ce93d8' }}>State Colocation</h3>
131 <p style={{ color: '#888', fontSize: 11, marginBottom: 8 }}>
132 Stan lokalny - bez Context/Redux
133 </p>
134 <input
135 style={styles.input}
136 placeholder="Szukaj planety..."
137 value={localSearch}
138 onChange={e => handleSearch(e.target.value)}
139 />
140 {results.length > 0 && (
141 <div style={styles.resultList}>
142 {results.map(r => (
143 <div key={r} style={styles.resultItem}>{r}</div>
144 ))}
145 </div>
146 )}
147 </div>
148 );
149}
150
151function ComparisonTable() {
152 const data = [
153 { pattern: 'useState', size: '0 KB', reRenders: 'Lokalne', bestFor: 'Stan jednego komponentu' },
154 { pattern: 'Context API', size: '0 KB', reRenders: 'Wszyscy konsumenci', bestFor: 'Theme, auth, locale' },
155 { pattern: 'useReducer', size: '0 KB', reRenders: 'Lokalne', bestFor: 'Złożona logika stanu' },
156 { pattern: 'Redux (RTK)', size: '~11 KB', reRenders: 'Selektywne', bestFor: 'Duże aplikacje, zespoły' },
157 { pattern: 'Zustand', size: '~1.5 KB', reRenders: 'Selektywne', bestFor: 'Większość aplikacji' },
158 ];
159
160 return (
161 <div style={{ ...styles.panel, gridColumn: '1 / -1' }}>
162 <h3 style={{ color: '#ffd700', textAlign: 'center' }}>Porównanie wzorców</h3>
163 <div style={styles.table}>
164 <div style={styles.tableHeader}>
165 <span>Wzorzec</span><span>Rozmiar</span><span>Re-rendery</span><span>Najlepsze dla</span>
166 </div>
167 {data.map(row => (
168 <div key={row.pattern} style={styles.tableRow}>
169 <span style={{ color: '#4fc3f7' }}>{row.pattern}</span>
170 <span>{row.size}</span>
171 <span>{row.reRenders}</span>
172 <span style={{ color: '#888' }}>{row.bestFor}</span>
173 </div>
174 ))}
175 </div>
176 </div>
177 );
178}
179
180function App() {
181 return (
182 <ThemeProvider>
183 <div style={styles.container}>
184 <h1 style={styles.title}>Wzorce Zarządzania Stanem</h1>
185 <p style={styles.subtitle}>
186 Porównanie Context API, useReducer i state colocation
187 </p>
188 <div style={styles.grid}>
189 <ContextDemo />
190 <ReducerDemo />
191 <ColocationDemo />
192 <ComparisonTable />
193 </div>
194 </div>
195 </ThemeProvider>
196 );
197}
198
199const styles = {
200 container: {
201 fontFamily: 'Arial, sans-serif',
202 background: 'linear-gradient(135deg, #0a0e1a 0%, #1a1a3e 100%)',
203 minHeight: '100vh',
204 padding: 24,
205 color: 'white',
206 },
207 title: { textAlign: 'center', color: '#ffd700', marginBottom: 4 },
208 subtitle: { textAlign: 'center', color: '#888', fontSize: 13, marginBottom: 24 },
209 grid: {
210 display: 'grid',
211 gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
212 gap: 16,
213 maxWidth: 900,
214 margin: '0 auto',
215 },
216 panel: {
217 background: '#1a2035',
218 borderRadius: 10,
219 padding: 16,
220 border: '1px solid rgba(79,195,247,0.2)',
221 },
222 stats: {
223 fontSize: 13,
224 lineHeight: 1.8,
225 marginBottom: 12,
226 padding: '8px 12px',
227 background: 'rgba(0,0,0,0.3)',
228 borderRadius: 8,
229 },
230 btnRow: { display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' },
231 btn: {
232 padding: '6px 16px',
233 background: 'rgba(255,255,255,0.08)',
234 color: 'white',
235 border: '1px solid rgba(255,255,255,0.15)',
236 borderRadius: 6,
237 cursor: 'pointer',
238 fontSize: 13,
239 },
240 input: {
241 width: '100%',
242 padding: '8px 12px',
243 background: 'rgba(0,0,0,0.3)',
244 color: 'white',
245 border: '1px solid rgba(79,195,247,0.3)',
246 borderRadius: 6,
247 fontSize: 13,
248 outline: 'none',
249 boxSizing: 'border-box',
250 },
251 resultList: {
252 marginTop: 8,
253 background: 'rgba(0,0,0,0.2)',
254 borderRadius: 6,
255 overflow: 'hidden',
256 },
257 resultItem: {
258 padding: '6px 12px',
259 borderBottom: '1px solid rgba(255,255,255,0.05)',
260 fontSize: 13,
261 color: '#ce93d8',
262 },
263 table: { fontSize: 12 },
264 tableHeader: {
265 display: 'grid',
266 gridTemplateColumns: '1fr 0.7fr 1fr 1.3fr',
267 gap: 8,
268 padding: '8px 0',
269 borderBottom: '1px solid rgba(255,215,0,0.3)',
270 fontWeight: 'bold',
271 color: '#ffd700',
272 },
273 tableRow: {
274 display: 'grid',
275 gridTemplateColumns: '1fr 0.7fr 1fr 1.3fr',
276 gap: 8,
277 padding: '6px 0',
278 borderBottom: '1px solid rgba(255,255,255,0.05)',
279 color: '#ccc',
280 },
281};
282
283export default App;Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Kiedy warto użyć Context API zamiast przekazywania propsów (props drilling)?
2. Co się stanie, gdy jeden duży kontekst przechowuje pozycję statku (aktualizowaną 60x/s) i poziom paliwa, a komponent FuelGauge odczytuje tylko paliwo?
To 2 z 5 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Stwórz trzy osobne konteksty (FuelContext, PositionContext, CrewContext) z własnymi providerami. Skomponuj je razem w ShipProviders. Wyświetl dane z każdego kontekstu w osobnych komponentach.
- Klikanie w kolejności
Ułóż kroki tworzenia custom hooka dla bezpiecznego dostępu do kontekstu:
- Układanie w pionie
Uporządkuj etapy budowy systemu rozdzielonych kontekstów od zewnątrz do wewnątrz:
- Edytor kodu
Zaimplementuj custom hook useFuelContext z walidacją dostępności kontekstu. Stwórz FuelProvider i komponent FuelPanel korzystający z hooka.