Kurs JavaScript i React · Moduł 4: Context API i useReducer

Wzorce zarządzania stanem

7 min czytania
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

CechaContext APIRedux ToolkitZustand
Rozmiar0 KB (wbudowany)~11 KB~1.5 KB
BoilerplateŚredniDuży (ale RTK pomaga)Minimalny
Re-renderyWszyscy konsumenciZoptymalizowane (selektory)Zoptymalizowane (selektory)
DevToolsBrak dedykowanychRozbudowane (time-travel)Rozszerzenie Redux DevTools
Krzywa naukiŁatwaStromaŁatwa
Najlepsze dlaTheme, auth, localeDuże aplikacje, duże zespołyWiększość aplikacji

Zasada wyboru narzędzia

Na Stacji Jupiter każdy system ma swoje przeznaczenie. Podobnie z zarządzaniem stanem:

  1. Stan lokalny (useState) - dane potrzebne tylko jednemu komponentowi lub jego bezpośrednim dzieciom
  2. Lifted state (stan podniesiony) - dane współdzielone przez 2-3 bliskie komponenty, przenieś do ich wspólnego rodzica
  3. Context API - dane globalne zmieniające się rzadko: theme, auth, locale, język
  4. Zustand - stan globalny w małych/średnich aplikacjach, gdy potrzebujesz wydajności i prostoty
  5. 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. 1. Kiedy warto użyć Context API zamiast przekazywania propsów (props drilling)?

  2. 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.

Przydatne artykuły