Kurs JavaScript i React · Moduł 4: Context API i useReducer
Context i useReducer w praktyce
W tej lekcji6
Poznałeś już Context API i useReducer osobno. Teraz czas połączyć te dwa narzędzia w jeden kompletny system zarządzania stanem - tak jak centrum dowodzenia misji kosmicznej łączy wszystkie systemy komunikacji w jeden spójny panel sterowania.
Dlaczego łączyć Context z useReducer?
Samo useReducer centralizuje logikę stanu, ale stan pozostaje lokalny w jednym komponencie. Samo Context API udostępnia dane globalnie, ale zarządzanie złożonymi aktualizacjami przez setState staje się nieuporządkowane. Połączenie obu daje nam najlepsze z dwóch światów:
- Context - udostępnia stan i
dispatchkażdemu komponentowi w drzewie bez prop drilling - useReducer - porządkuje całą logikę aktualizacji w jednej, testowalnej funkcji
Analogia kosmiczna: Context to sieć komunikacyjna statku, która dociera do każdej kabiny. useReducer to centralny komputer, który przetwarza wszystkie rozkazy. Razem tworzą Mission Control - każdy członek załogi może wysłać rozkaz i otrzymać aktualny status, bez konieczności biegania po korytarzach z karteczkami.
Wzorzec: kontekst ze stanem i dispatch
Kluczowy wzorzec polega na umieszczeniu zarówno state, jak i dispatch w wartości kontekstu:
1import { createContext, useContext, useReducer } from 'react';
2
3// Tworzymy kontekst
4const MissionContext = createContext();
5
6// Reducer z logiką stanu
7function missionReducer(state, action) {
8 switch (action.type) {
9 case 'SET_STATUS':
10 return { ...state, status: action.payload };
11 case 'ADD_CREW':
12 return { ...state, crew: [...state.crew, action.payload] };
13 case 'UPDATE_FUEL':
14 return { ...state, fuel: Math.max(0, Math.min(100, action.payload)) };
15 default:
16 return state;
17 }
18}
19
20// Provider - łączy Context z useReducer
21function MissionProvider({ children }) {
22 const initialState = {
23 status: 'docked',
24 crew: ['Kapitan Nova'],
25 fuel: 100
26 };
27
28 const [state, dispatch] = useReducer(missionReducer, initialState);
29
30 return (
31 <MissionContext.Provider value={{ state, dispatch }}>
32 {children}
33 </MissionContext.Provider>
34 );
35}Niestandardowy hook dostępu do kontekstu
Zamiast wywoływać useContext(MissionContext) w każdym komponencie, tworzymy dedykowany hook. To standardowa konwencja, która:
- Chroni przed użyciem kontekstu poza providerem (zgłasza czytelny błąd)
- Upraszcza importy w komponentach
- Ułatwia przyszłe refaktoryzacje
1function useMission() {
2 const context = useContext(MissionContext);
3 if (!context) {
4 throw new Error('useMission musi być używany wewnątrz MissionProvider');
5 }
6 return context;
7}Używanie dispatch z komponentów potomnych
Każdy komponent wewnątrz providera może teraz bezpośrednio wysyłać akcje - bez przekazywania funkcji przez props:
1function StatusPanel() {
2 const { state, dispatch } = useMission();
3
4 return (
5 <div className="status-panel">
6 <h3>Status misji: {state.status}</h3>
7 <button onClick={() => dispatch({ type: 'SET_STATUS', payload: 'launching' })}>
8 Rozpocznij start
9 </button>
10 <button onClick={() => dispatch({ type: 'SET_STATUS', payload: 'orbiting' })}>
11 Wejdź na orbitę
12 </button>
13 <button onClick={() => dispatch({ type: 'SET_STATUS', payload: 'docked' })}>
14 Dokuj
15 </button>
16 </div>
17 );
18}
19
20function CrewPanel() {
21 const { state, dispatch } = useMission();
22 const [newMember, setNewMember] = React.useState('');
23
24 const addMember = () => {
25 if (newMember.trim()) {
26 dispatch({ type: 'ADD_CREW', payload: newMember.trim() });
27 setNewMember('');
28 }
29 };
30
31 return (
32 <div className="crew-panel">
33 <h3>Załoga ({state.crew.length}):</h3>
34 <ul>
35 {state.crew.map((member, i) => (
36 <li key={i}>{member}</li>
37 ))}
38 </ul>
39 <input
40 value={newMember}
41 onChange={(e) => setNewMember(e.target.value)}
42 placeholder="Imię nowego członka załogi"
43 />
44 <button onClick={addMember}>Dodaj do załogi</button>
45 </div>
46 );
47}Wzorzec wielu reducerów
Gdy aplikacja rośnie, jeden duży reducer staje się trudny w utrzymaniu. Możemy podzielić logikę na mniejsze reduktory według domeny i połączyć je w głównym reducerze:
1// Reduktor dla systemu nawigacji
2function navigationReducer(navState, action) {
3 switch (action.type) {
4 case 'SET_DESTINATION':
5 return { ...navState, destination: action.payload, status: 'en route' };
6 case 'ARRIVE':
7 return { ...navState, location: navState.destination, destination: null, status: 'arrived' };
8 default:
9 return navState;
10 }
11}
12
13// Reduktor dla systemów pokładowych
14function systemsReducer(systemsState, action) {
15 switch (action.type) {
16 case 'TOGGLE_SHIELDS':
17 return { ...systemsState, shields: !systemsState.shields };
18 case 'SET_ENGINE_POWER':
19 return { ...systemsState, enginePower: action.payload };
20 default:
21 return systemsState;
22 }
23}
24
25// Główny reduktor łączący oba
26function rootReducer(state, action) {
27 return {
28 navigation: navigationReducer(state.navigation, action),
29 systems: systemsReducer(state.systems, action)
30 };
31}
32
33// Provider z głównym reduktorem
34function SpaceshipProvider({ children }) {
35 const initialState = {
36 navigation: { location: 'Earth', destination: null, status: 'docked' },
37 systems: { shields: false, enginePower: 0 }
38 };
39
40 const [state, dispatch] = useReducer(rootReducer, initialState);
41
42 return (
43 <SpaceshipContext.Provider value={{ state, dispatch }}>
44 {children}
45 </SpaceshipContext.Provider>
46 );
47}Wzorzec wielu reducerów pozwala na czysty podział odpowiedzialności. Każdy mini-reduktor obsługuje swój obszar stanu, ale wszystkie akcje przepływają przez jeden dispatch. Dzięki temu akcja TOGGLE_SHIELDS może jednocześnie wpłynąć na systemy i nawigację, jeśli tego potrzebujemy.
Kompletna aplikacja Mission Control
Poniżej widzisz wszystkie elementy zebrane razem - od definicji kontekstu, przez provider, po komponenty używające useMission:
1import { createContext, useContext, useReducer } from 'react';
2
3const MissionContext = createContext();
4
5function missionReducer(state, action) {
6 switch (action.type) {
7 case 'LAUNCH':
8 return { ...state, status: 'flying', fuel: state.fuel - 20 };
9 case 'DOCK':
10 return { ...state, status: 'docked', speed: 0 };
11 case 'ADD_LOG':
12 return { ...state, log: [...state.log, action.payload] };
13 default:
14 return state;
15 }
16}
17
18function MissionProvider({ children }) {
19 const [state, dispatch] = useReducer(missionReducer, {
20 status: 'docked',
21 fuel: 100,
22 speed: 0,
23 log: []
24 });
25
26 return (
27 <MissionContext.Provider value={{ state, dispatch }}>
28 {children}
29 </MissionContext.Provider>
30 );
31}
32
33function useMission() {
34 return useContext(MissionContext);
35}
36
37// Komponent A - panel sterowania
38function ControlPanel() {
39 const { state, dispatch } = useMission();
40 return (
41 <div>
42 <button
43 onClick={() => {
44 dispatch({ type: 'LAUNCH' });
45 dispatch({ type: 'ADD_LOG', payload: 'Start misji!' });
46 }}
47 disabled={state.status === 'flying'}
48 >
49 Start
50 </button>
51 </div>
52 );
53}
54
55// Komponent B - wyświetlanie stanu (osobna gałąź drzewa)
56function MissionLog() {
57 const { state } = useMission();
58 return (
59 <ul>
60 {state.log.map((entry, i) => <li key={i}>{entry}</li>)}
61 </ul>
62 );
63}
64
65// Aplikacja
66function App() {
67 return (
68 <MissionProvider>
69 <ControlPanel />
70 <MissionLog />
71 </MissionProvider>
72 );
73}Zauważ, że ControlPanel i MissionLog mogą znajdować się na różnych głębokościach drzewa komponentów i nie muszą o sobie nawzajem wiedzieć - łączy je tylko wspólny MissionProvider.
Kod do tej lekcji: App.jsx
1import React, { createContext, useContext, useReducer } from 'react';
2
3// --- Reducer dla misji ---
4const initialState = {
5 missions: [
6 { id: 1, name: 'Eksploracja Marsa', priority: 'high', completed: false },
7 { id: 2, name: 'Sonda Jowisza', priority: 'medium', completed: false },
8 ],
9};
10
11function missionReducer(state, action) {
12 switch (action.type) {
13 case 'ADD_MISSION':
14 return {
15 ...state,
16 missions: [
17 ...state.missions,
18 { id: Date.now(), name: action.name, priority: action.priority, completed: false },
19 ],
20 };
21 case 'COMPLETE_MISSION':
22 return {
23 ...state,
24 missions: state.missions.map(m =>
25 m.id === action.id ? { ...m, completed: true } : m
26 ),
27 };
28 case 'SET_PRIORITY':
29 return {
30 ...state,
31 missions: state.missions.map(m =>
32 m.id === action.id ? { ...m, priority: action.priority } : m
33 ),
34 };
35 default:
36 return state;
37 }
38}
39
40// --- Context ---
41const MissionContext = createContext();
42
43function MissionProvider({ children }) {
44 const [state, dispatch] = useReducer(missionReducer, initialState);
45 return (
46 <MissionContext.Provider value={{ state, dispatch }}>
47 {children}
48 </MissionContext.Provider>
49 );
50}
51
52function useMission() {
53 return useContext(MissionContext);
54}
55
56// --- Komponenty ---
57const priorityColors = { high: '#f44336', medium: '#ff9800', low: '#4caf50' };
58
59function MissionList() {
60 const { state, dispatch } = useMission();
61 return (
62 <div style={panel}>
63 <h2 style={{ color: '#4fc3f7', marginTop: 0 }}>Lista misji</h2>
64 {state.missions.map(m => (
65 <div key={m.id} style={{ ...missionItem, opacity: m.completed ? 0.5 : 1 }}>
66 <span style={{ flex: 1 }}>{m.name}</span>
67 <span style={{ color: priorityColors[m.priority], marginRight: 8 }}>
68 [{m.priority}]
69 </span>
70 {!m.completed && (
71 <>
72 <select
73 value={m.priority}
74 onChange={e => dispatch({ type: 'SET_PRIORITY', id: m.id, priority: e.target.value })}
75 style={selectStyle}
76 >
77 <option value="high">Wysoki</option>
78 <option value="medium">Średni</option>
79 <option value="low">Niski</option>
80 </select>
81 <button
82 onClick={() => dispatch({ type: 'COMPLETE_MISSION', id: m.id })}
83 style={btnStyle}
84 >
85 Zakończ
86 </button>
87 </>
88 )}
89 {m.completed && <span style={{ color: '#4caf50' }}>Ukończona</span>}
90 </div>
91 ))}
92 </div>
93 );
94}
95
96function AddMission() {
97 const { dispatch } = useMission();
98 const [name, setName] = React.useState('');
99 const [priority, setPriority] = React.useState('medium');
100
101 const handleAdd = () => {
102 if (!name.trim()) return;
103 dispatch({ type: 'ADD_MISSION', name, priority });
104 setName('');
105 };
106
107 return (
108 <div style={panel}>
109 <h2 style={{ color: '#4fc3f7', marginTop: 0 }}>Nowa misja</h2>
110 <input
111 value={name}
112 onChange={e => setName(e.target.value)}
113 placeholder="Nazwa misji..."
114 style={inputStyle}
115 />
116 <select value={priority} onChange={e => setPriority(e.target.value)} style={selectStyle}>
117 <option value="high">Wysoki priorytet</option>
118 <option value="medium">Średni priorytet</option>
119 <option value="low">Niski priorytet</option>
120 </select>
121 <button onClick={handleAdd} style={{ ...btnStyle, background: '#4fc3f7', color: '#0a0e1a' }}>
122 Dodaj misję
123 </button>
124 </div>
125 );
126}
127
128function MissionStats() {
129 const { state } = useMission();
130 const total = state.missions.length;
131 const done = state.missions.filter(m => m.completed).length;
132 return (
133 <div style={{ ...panel, display: 'flex', gap: 20 }}>
134 <span style={{ color: '#adb5bd' }}>Wszystkich: <b style={{ color: 'white' }}>{total}</b></span>
135 <span style={{ color: '#adb5bd' }}>Ukończonych: <b style={{ color: '#4caf50' }}>{done}</b></span>
136 <span style={{ color: '#adb5bd' }}>Aktywnych: <b style={{ color: '#4fc3f7' }}>{total - done}</b></span>
137 </div>
138 );
139}
140
141export default function App() {
142 return (
143 <MissionProvider>
144 <div style={{ minHeight: '100vh', background: '#0a0e1a', padding: 24, fontFamily: 'Arial', color: 'white' }}>
145 <h1 style={{ textAlign: 'center', color: '#ffd700', marginBottom: 24 }}>
146 Centrum Dowodzenia Misjami
147 </h1>
148 <MissionStats />
149 <AddMission />
150 <MissionList />
151 </div>
152 </MissionProvider>
153 );
154}
155
156// --- Style ---
157const panel = {
158 background: '#1a2035',
159 borderRadius: 10,
160 padding: 16,
161 marginBottom: 16,
162 border: '1px solid rgba(79,195,247,0.2)',
163};
164const missionItem = {
165 display: 'flex', alignItems: 'center', gap: 8,
166 padding: '8px 0', borderBottom: '1px solid rgba(255,255,255,0.07)',
167};
168const btnStyle = {
169 background: 'rgba(255,255,255,0.1)', color: 'white',
170 border: '1px solid rgba(255,255,255,0.2)', borderRadius: 6,
171 padding: '4px 10px', cursor: 'pointer',
172};
173const selectStyle = {
174 background: '#0a0e1a', color: 'white',
175 border: '1px solid rgba(79,195,247,0.3)', borderRadius: 6, padding: '4px 6px',
176};
177const inputStyle = {
178 background: '#0a0e1a', color: 'white',
179 border: '1px solid rgba(79,195,247,0.3)', borderRadius: 6,
180 padding: '6px 10px', width: '100%', marginBottom: 8, boxSizing: 'border-box',
181};Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Dlaczego funkcje reducer muszą być 'pure functions' i nie mutować stanu?
2. Jak zaimplementować lazy initialization w useReducer?
Zadania praktyczne w grze
- Układanie w pionie
Uporządkuj progresję złożoności stanu - od prostego do wymagającego useReducer:
- Układanie w poziomie
Ułóż elementy składni case w reducer od lewej do prawej:
- Edytor kodu
Implementuj portal międzygalaktyczny
- Klikanie w kolejności
Uporządkuj strukturę reducer'a kosmicznego:
- Edytor kodu
Zbuduj centrum handlowe galaktyki