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

Context i useReducer w praktyce

5 min czytania
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 dispatch każ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. 1. Dlaczego funkcje reducer muszą być 'pure functions' i nie mutować stanu?

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

Przydatne artykuły