Wraz z rozwojem aplikacji React, zarządzanie stanem staje się coraz bardziej złożonym wyzwaniem. W małych aplikacjach, lokalny stan komponentów i props są wystarczające, ale w miarę skalowania, potrzebujemy bardziej zaawansowanych rozwiązań do zarządzania globalnym stanem aplikacji.
W tym module przyjrzymy się różnym podejściom do globalnego zarządzania stanem w React, od wbudowanych mechanizmów po popularne biblioteki zewnętrzne.
Zanim zagłębimy się w rozwiązania, zrozummy dlaczego zarządzanie globalnym stanem może być wyzwaniem:
Jak poznaliśmy w poprzednich modułach, połączenie Context API z useReducer zapewnia solidne podstawy do globalnego zarządzania stanem:
1import React, { createContext, useReducer, useContext } from 'react';
2
3// Początkowy stan
4const initialState = {
5 missions: [],
6 activeMission: null,
7 user: null,
8 isLoading: false,
9 error: null
10};
11
12// Reducer
13function appReducer(state, action) {
14 switch (action.type) {
15 case 'LOGIN_SUCCESS':
16 return { ...state, user: action.payload, error: null };
17 case 'LOGOUT':
18 return { ...state, user: null };
19 case 'FETCH_MISSIONS_START':
20 return { ...state, isLoading: true };
21 case 'FETCH_MISSIONS_SUCCESS':
22 return { ...state, missions: action.payload, isLoading: false };
23 case 'FETCH_MISSIONS_ERROR':
24 return { ...state, error: action.payload, isLoading: false };
25 case 'SET_ACTIVE_MISSION':
26 return { ...state, activeMission: action.payload };
27 default:
28 return state;
29 }
30}
31
32// Kontekst
33const AppStateContext = createContext();
34
35// Provider
36function AppStateProvider({ children }) {
37 const [state, dispatch] = useReducer(appReducer, initialState);
38
39 // Możemy także dodać funkcje pomocnicze
40 const login = async (credentials) => {
41 try {
42 dispatch({ type: 'LOGIN_START' });
43 // API call do logowania
44 const user = await authService.login(credentials);
45 dispatch({ type: 'LOGIN_SUCCESS', payload: user });
46 } catch (error) {
47 dispatch({ type: 'LOGIN_ERROR', payload: error.message });
48 }
49 };
50
51 const fetchMissions = async () => {
52 try {
53 dispatch({ type: 'FETCH_MISSIONS_START' });
54 const missions = await missionService.getAll();
55 dispatch({ type: 'FETCH_MISSIONS_SUCCESS', payload: missions });
56 } catch (error) {
57 dispatch({ type: 'FETCH_MISSIONS_ERROR', payload: error.message });
58 }
59 };
60
61 // Wartość kontekstu
62 const value = {
63 state,
64 dispatch,
65 login,
66 fetchMissions,
67 setActiveMission: (mission) => dispatch({ type: 'SET_ACTIVE_MISSION', payload: mission })
68 };
69
70 return (
71 <AppStateContext.Provider value={value}>
72 {children}
73 </AppStateContext.Provider>
74 );
75}
76
77// Hook do korzystania z kontekstu
78function useAppState() {
79 const context = useContext(AppStateContext);
80 if (context === undefined) {
81 throw new Error('useAppState must be used within an AppStateProvider');
82 }
83 return context;
84}
85
86// Użycie w aplikacji
87function App() {
88 return (
89 <AppStateProvider>
90 <Header />
91 <Main />
92 <Footer />
93 </AppStateProvider>
94 );
95}
96
97function Header() {
98 const { state, dispatch } = useAppState();
99
100 return (
101 <header>
102 {state.user ? (
103 <>
104 <span>Witaj, {state.user.name}</span>
105 <button onClick={() => dispatch({ type: 'LOGOUT' })}>Wyloguj</button>
106 </>
107 ) : (
108 <LoginButton />
109 )}
110 </header>
111 );
112}
113
114function MissionsList() {
115 const { state, fetchMissions, setActiveMission } = useAppState();
116
117 useEffect(() => {
118 fetchMissions();
119 }, []);
120
121 if (state.isLoading) return <p>Ładowanie misji...</p>;
122 if (state.error) return <p>Błąd: {state.error}</p>;
123
124 return (
125 <div>
126 <h2>Dostępne misje</h2>
127 <ul>
128 {state.missions.map(mission => (
129 <li key={mission.id}>
130 {mission.name}
131 <button onClick={() => setActiveMission(mission)}>
132 Wybierz
133 </button>
134 </li>
135 ))}
136 </ul>
137 </div>
138 );
139}To rozwiązanie ma kilka zalet:
Jednakże w miarę jak aplikacja rośnie, to rozwiązanie może napotykać ograniczenia związane z wydajnością i organizacją kodu.
Dla większych aplikacji zaleca się podział globalnego stanu na mniejsze, niezależne konteksty według domen:
1// Kontekst autentykacji
2const AuthContext = createContext();
3
4function AuthProvider({ children }) {
5 const [user, setUser] = useState(null);
6 const [isLoading, setIsLoading] = useState(false);
7 const [error, setError] = useState(null);
8
9 const login = async (credentials) => {
10 setIsLoading(true);
11 try {
12 const user = await authService.login(credentials);
13 setUser(user);
14 setError(null);
15 } catch (err) {
16 setError(err.message);
17 } finally {
18 setIsLoading(false);
19 }
20 };
21
22 const logout = async () => {
23 await authService.logout();
24 setUser(null);
25 };
26
27 return (
28 <AuthContext.Provider value={{ user, isLoading, error, login, logout }}>
29 {children}
30 </AuthContext.Provider>
31 );
32}
33
34// Kontekst misji
35const MissionsContext = createContext();
36
37function MissionsProvider({ children }) {
38 const [missions, setMissions] = useReducer(missionsReducer, []);
39 const [activeMission, setActiveMission] = useState(null);
40 // reszta logiki...
41
42 return (
43 <MissionsContext.Provider value={{ missions, activeMission, /*...*/ }}>
44 {children}
45 </MissionsContext.Provider>
46 );
47}
48
49// Kontekst UI
50const UIContext = createContext();
51
52function UIProvider({ children }) {
53 const [darkMode, setDarkMode] = useState(false);
54 const [sidebarOpen, setSidebarOpen] = useState(true);
55 // reszta logiki...
56
57 return (
58 <UIContext.Provider value={{ darkMode, toggleDarkMode: () => setDarkMode(!darkMode), /*...*/ }}>
59 {children}
60 </UIContext.Provider>
61 );
62}
63
64// Łączenie wszystkich providerów
65function AppProviders({ children }) {
66 return (
67 <AuthProvider>
68 <MissionsProvider>
69 <UIProvider>
70 {children}
71 </UIProvider>
72 </MissionsProvider>
73 </AuthProvider>
74 );
75}Zalety tego podejścia:
Jednym z wyzwań przy używaniu Context API jest wydajność - każda zmiana wartości kontekstu powoduje przerenderowanie wszystkich komponentów, które używają tego kontekstu. Oto kilka technik optymalizacji:
1function OptimizedProvider({ children }) {
2 const [state, dispatch] = useReducer(reducer, initialState);
3
4 // Memizacja wartości kontekstu
5 const contextValue = useMemo(() => {
6 return { state, dispatch };
7 }, [state, dispatch]);
8
9 return (
10 <AppContext.Provider value={contextValue}>
11 {children}
12 </AppContext.Provider>
13 );
14}
15
16// Optymalizacja komponentów konsumujących
17const MissionItem = memo(function MissionItem({ mission, onSelect }) {
18 return (
19 <li>
20 {mission.name}
21 <button onClick={() => onSelect(mission)}>Wybierz</button>
22 </li>
23 );
24});
25
26function MissionList() {
27 const { missions, selectMission } = useMissions();
28
29 // Memizacja callbacka
30 const handleSelect = useCallback((mission) => {
31 selectMission(mission);
32 }, [selectMission]);
33
34 return (
35 <ul>
36 {missions.map(mission => (
37 <MissionItem
38 key={mission.id}
39 mission={mission}
40 onSelect={handleSelect}
41 />
42 ))}
43 </ul>
44 );
45}Redux to popularna biblioteka do zarządzania stanem, która oferuje bardziej ustrukturyzowane podejście do globalnego stanu. Dzisiaj nowoczesny Redux to Redux Toolkit (RTK) - oficjalny, rekomendowany zestaw narzędzi, który eliminuje większość boilerplate'u. Zamiast ręcznie pisać reducery z instrukcją
switch, używamy createSlice, a zamiast createStore - configureStore. Oto podstawy Redux Toolkit z React:1import { configureStore, createSlice } from '@reduxjs/toolkit';
2import { Provider, useSelector, useDispatch } from 'react-redux';
3
4// Slice = fragment stanu + reducery + akcje w jednym miejscu
5const spaceAppSlice = createSlice({
6 name: 'spaceApp',
7 initialState: {
8 missions: [],
9 user: null
10 },
11 reducers: {
12 // Dzięki Immerowi możemy "mutować" stan - RTK robi to bezpiecznie i niemutowalnie
13 setUser: (state, action) => {
14 state.user = action.payload;
15 },
16 setMissions: (state, action) => {
17 state.missions = action.payload;
18 }
19 }
20});
21
22// createSlice automatycznie generuje kreatory akcji
23export const { setUser, setMissions } = spaceAppSlice.actions;
24
25// Store - configureStore sam dodaje DevTools i middleware
26const store = configureStore({
27 reducer: spaceAppSlice.reducer
28});
29
30// Provider
31function App() {
32 return (
33 <Provider store={store}>
34 <Main />
35 </Provider>
36 );
37}
38
39// Użycie w komponentach
40function UserProfile() {
41 // Wybieranie części stanu
42 const user = useSelector(state => state.user);
43 const dispatch = useDispatch();
44
45 if (!user) return <p>Użytkownik nie zalogowany</p>;
46
47 return (
48 <div>
49 <h2>{user.name}</h2>
50 <button onClick={() => dispatch(setUser(null))}>
51 Wyloguj
52 </button>
53 </div>
54 );
55}Uwaga historyczna: Klasyczny Redux używał
i ręcznych reducerów z instrukcjącreateStore. To podejście (switch) jest dziś przestarzałe (deprecated) - oficjalna dokumentacja zaleca Redux Toolkit. Dlatego od razu uczymy się nowoczesnego sposobu.import { createStore } from 'redux'
W większych aplikacjach każdy fragment stanu to osobny slice.
configureStore łączy je automatycznie - wystarczy podać mapę reducerów (nie potrzeba już ręcznego combineReducers):1// /store/index.js
2import { configureStore } from '@reduxjs/toolkit';
3import authReducer from './auth/authSlice';
4import missionsReducer from './missions/missionsSlice';
5import uiReducer from './ui/uiSlice';
6
7// configureStore łączy reducery sam - bez combineReducers
8const store = configureStore({
9 reducer: {
10 auth: authReducer,
11 missions: missionsReducer,
12 ui: uiReducer
13 }
14});
15export default store;
16
17// /store/missions/missionsSlice.js
18import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
19
20// Operacje asynchroniczne obsługuje createAsyncThunk
21export const fetchMissions = createAsyncThunk(
22 'missions/fetchMissions',
23 async () => {
24 const missions = await api.fetchMissions();
25 return missions;
26 }
27);
28
29const missionsSlice = createSlice({
30 name: 'missions',
31 initialState: {
32 list: [],
33 active: null,
34 loading: false,
35 error: null
36 },
37 reducers: {
38 setActiveMission: (state, action) => {
39 state.active = action.payload;
40 }
41 },
42 // extraReducers reagują na stany thunka (pending/fulfilled/rejected)
43 extraReducers: (builder) => {
44 builder
45 .addCase(fetchMissions.pending, (state) => {
46 state.loading = true;
47 })
48 .addCase(fetchMissions.fulfilled, (state, action) => {
49 state.list = action.payload;
50 state.loading = false;
51 })
52 .addCase(fetchMissions.rejected, (state, action) => {
53 state.error = action.error.message;
54 state.loading = false;
55 });
56 }
57});
58
59export const { setActiveMission } = missionsSlice.actions;
60export default missionsSlice.reducer;
61
62// /store/missions/selectors.js
63export const selectMissions = state => state.missions.list;
64export const selectActiveMission = state => state.missions.active;
65export const selectMissionsLoading = state => state.missions.loading;W ekosystemie React pojawiło się wiele alternatywnych bibliotek do zarządzania stanem, które starają się uprościć proces i zmniejszyć ilość kodu boilerplate. Oto kilka popularnych opcji:
Zustand to minimalistyczna biblioteka, która łączy prostotę hooka z potęgą store:
1import create from 'zustand';
2
3// Utworzenie store
4const useSpaceStore = create((set) => ({
5 missions: [],
6 activeMissionId: null,
7 isLoading: false,
8
9 fetchMissions: async () => {
10 set({ isLoading: true });
11 try {
12 const missions = await api.fetchMissions();
13 set({ missions, isLoading: false });
14 } catch (error) {
15 set({ error: error.message, isLoading: false });
16 }
17 },
18
19 setActiveMission: (id) => set({ activeMissionId: id })
20}));
21
22// Użycie w komponencie
23function MissionPanel() {
24 // Selektywne pobieranie danych ze store
25 const missions = useSpaceStore(state => state.missions);
26 const isLoading = useSpaceStore(state => state.isLoading);
27 const fetchMissions = useSpaceStore(state => state.fetchMissions);
28
29 useEffect(() => {
30 fetchMissions();
31 }, [fetchMissions]);
32
33 if (isLoading) return <p>Ładowanie...</p>;
34
35 return (
36 <div>
37 <h2>Misje</h2>
38 <ul>
39 {missions.map(mission => (
40 <MissionItem key={mission.id} mission={mission} />
41 ))}
42 </ul>
43 </div>
44 );
45}Recoil to biblioteka stworzona przez Facebook, która wprowadza koncepcję "atomów" i "selektorów":
1import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';
2
3// Definiowanie atomów (najmniejszych jednostek stanu)
4const missionsState = atom({
5 key: 'missionsState',
6 default: []
7});
8
9const missionsFilterState = atom({
10 key: 'missionsFilterState',
11 default: 'all' // 'all', 'active', 'completed'
12});
13
14// Selektor (pochodny stan)
15const filteredMissionsState = selector({
16 key: 'filteredMissionsState',
17 get: ({ get }) => {
18 const missions = get(missionsState);
19 const filter = get(missionsFilterState);
20
21 switch (filter) {
22 case 'active':
23 return missions.filter(mission => mission.status === 'active');
24 case 'completed':
25 return missions.filter(mission => mission.status === 'completed');
26 default:
27 return missions;
28 }
29 }
30});
31
32// Użycie w komponentach
33function MissionsFilter() {
34 const [filter, setFilter] = useRecoilState(missionsFilterState);
35
36 return (
37 <div>
38 <button
39 className={filter === 'all' ? 'active' : ''}
40 onClick={() => setFilter('all')}
41 >
42 Wszystkie
43 </button>
44 <button
45 className={filter === 'active' ? 'active' : ''}
46 onClick={() => setFilter('active')}
47 >
48 Aktywne
49 </button>
50 <button
51 className={filter === 'completed' ? 'active' : ''}
52 onClick={() => setFilter('completed')}
53 >
54 Zakończone
55 </button>
56 </div>
57 );
58}
59
60function MissionsList() {
61 // Używanie filtrowanej wartości
62 const missions = useRecoilValue(filteredMissionsState);
63
64 return (
65 <ul>
66 {missions.map(mission => (
67 <li key={mission.id}>{mission.name}</li>
68 ))}
69 </ul>
70 );
71}Jotai, podobnie jak Recoil, używa podejścia opartego na atomach, ale z bardziej minimalistycznym API:
1import { atom, useAtom } from 'jotai';
2
3// Definicja atomów
4const userAtom = atom(null);
5const missionsAtom = atom([]);
6const missionFilterAtom = atom('all');
7
8// Atom pochodny
9const filteredMissionsAtom = atom(
10 (get) => {
11 const missions = get(missionsAtom);
12 const filter = get(missionFilterAtom);
13
14 switch (filter) {
15 case 'active':
16 return missions.filter(m => m.status === 'active');
17 case 'completed':
18 return missions.filter(m => m.status === 'completed');
19 default:
20 return missions;
21 }
22 }
23);
24
25// Komponent
26function MissionDashboard() {
27 const [missions, setMissions] = useAtom(missionsAtom);
28 const [filter, setFilter] = useAtom(missionFilterAtom);
29 const [filteredMissions] = useAtom(filteredMissionsAtom);
30
31 // Logika komponentu...
32
33 return (
34 <div>
35 {/* UI */}
36 </div>
37 );
38}Jak wybrać najlepsze rozwiązanie do zarządzania stanem dla swojej aplikacji? Oto kilka wskazówek:
Rozmiar aplikacji:
Złożoność stanu:
Wymagania dotyczące wydajności:
Doświadczenie zespołu:
Niezależnie od wybranego rozwiązania, oto kilka ogólnych najlepszych praktyk dotyczących zarządzania globalnym stanem:
Minimalizuj globalny stan - nie wszystko musi być globalne; używaj lokalnego stanu dla danych specyficznych dla komponentu
Dziel stan według domen logicznych - grupuj powiązane dane i logikę razem
Normalizuj dane - unikaj duplikacji i głębokich zagnieżdżeń
Zachowuj niezmienność - nigdy nie modyfikuj stanu bezpośrednio
Dokumentuj intencje - używaj opisowych nazw akcji i selektorów
Używaj selektorów - do pobierania pochodnych danych
Trzymaj logikę biznesową poza komponentami - w akcjach, reducerach lub dedykowanych hookach
1// Przykład: Hook z logiką biznesową
2function useMissionControl() {
3 const { state, dispatch } = useAppState();
4
5 const launchMission = useCallback(async (missionId) => {
6 // Sprawdzenie warunków biznesowych
7 if (!state.user || state.user.role !== 'commander') {
8 throw new Error('Tylko dowódca może rozpocząć misję');
9 }
10
11 if (state.weather === 'storm') {
12 throw new Error('Nie można wystartować podczas burzy');
13 }
14
15 // Logika biznesowa
16 dispatch({ type: 'MISSION_LAUNCH_START', payload: missionId });
17
18 try {
19 await missionService.launch(missionId);
20 dispatch({ type: 'MISSION_LAUNCH_SUCCESS', payload: missionId });
21 } catch (error) {
22 dispatch({ type: 'MISSION_LAUNCH_FAILURE', payload: error.message });
23 throw error;
24 }
25 }, [state.user, state.weather, dispatch]);
26
27 return {
28 missions: state.missions,
29 activeMission: state.activeMission,
30 isLaunching: state.isLaunching,
31 launchError: state.launchError,
32 launchMission
33 };
34}Globalne zarządzanie stanem jest kluczowym aspektem budowania skalowalnych aplikacji React. W tym module poznaliśmy różne podejścia:
Context API + useReducer - wbudowane rozwiązanie React, dobre dla mniejszych i średnich aplikacji
Podział na wiele kontekstów - sposób na organizację globalnego stanu według domen
Optymalizacja wydajności - techniki minimalizacji przerenderowywania
Redux - tradycyjna biblioteka z ustrukturyzowanym podejściem do zarządzania stanem
Nowoczesne alternatywy - Zustand, Recoil, Jotai oferujące prostsze API i mniej kodu
Wybór odpowiedniego rozwiązania zależy od wielkości aplikacji, złożoności stanu i preferencji zespołu. Niezależnie od wyboru, postępowanie zgodnie z najlepszymi praktykami pomoże utrzymać czysty, przewidywalny i wydajny kod.