Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds

Globalne zarządzanie stanem

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.

Wyzwania związane z globalnym stanem

Zanim zagłębimy się w rozwiązania, zrozummy dlaczego zarządzanie globalnym stanem może być wyzwaniem:

  1. Współdzielenie danych - komponenty w różnych częściach drzewa komponentów mogą potrzebować dostępu do tych samych danych
  2. Synchronizacja - zmiany w jednym miejscu powinny być widoczne wszędzie, gdzie dane są używane
  3. Przewidywalność - aktualizacje stanu powinny być przewidywalne i łatwe do śledzenia
  4. Wydajność - niepotrzebne renderowanie powinno być minimalizowane
  5. Złożoność - logika stanu może być skomplikowana, z wieloma powiązanymi zmianami

Context API + useReducer jako podstawowe rozwiązanie

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:

  • Używa wbudowanych mechanizmów React
  • Nie wymaga zewnętrznych zależności
  • Daje dobre podstawy strukturalne dla globalnego stanu

Jednakże w miarę jak aplikacja rośnie, to rozwiązanie może napotykać ograniczenia związane z wydajnością i organizacją kodu.

Skalowanie aplikacji z wieloma kontekstami

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:

  • Lepszy podział odpowiedzialności
  • Mniejsze konteksty = mniej niepotrzebnych renderowań
  • Łatwiejsze testowanie poszczególnych modułów
  • Komponenty mogą korzystać tylko z potrzebnych kontekstów

Optymalizacja wydajności z useMemo i memo

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}

Wprowadzenie do Redux Toolkit

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ł

createStore
i ręcznych reducerów z instrukcją
switch
. To podejście (
import { createStore } from 'redux'
) jest dziś przestarzałe (deprecated) - oficjalna dokumentacja zaleca Redux Toolkit. Dlatego od razu uczymy się nowoczesnego sposobu.

Organizacja kodu w Redux Toolkit

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;

Nowoczesne alternatywy

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:

1. Zustand

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}

2. Recoil

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}

3. Jotai

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}

Wybór odpowiedniego rozwiązania

Jak wybrać najlepsze rozwiązanie do zarządzania stanem dla swojej aplikacji? Oto kilka wskazówek:

  1. Rozmiar aplikacji:

    • Małe aplikacje: useState + useReducer + Context API
    • Średnie aplikacje: Zustand lub Jotai
    • Duże aplikacje: Redux (z Redux Toolkit) lub Recoil
  2. Złożoność stanu:

    • Prosty stan: useState
    • Średnio złożony stan: useReducer + Context API
    • Złożony stan z relacjami: Redux lub Recoil
  3. Wymagania dotyczące wydajności:

    • Jeśli wydajność renderowania jest kluczowa, rozważ biblioteki, które zapewniają granularną reaktywność (Recoil, Jotai)
  4. Doświadczenie zespołu:

    • Jeśli zespół zna Redux, może warto przy nim pozostać
    • Dla nowych projektów, prostsze API Zustand czy Jotai może przyspieszyć rozwój

Najlepsze praktyki

Niezależnie od wybranego rozwiązania, oto kilka ogólnych najlepszych praktyk dotyczących zarządzania globalnym stanem:

  1. Minimalizuj globalny stan - nie wszystko musi być globalne; używaj lokalnego stanu dla danych specyficznych dla komponentu

  2. Dziel stan według domen logicznych - grupuj powiązane dane i logikę razem

  3. Normalizuj dane - unikaj duplikacji i głębokich zagnieżdżeń

  4. Zachowuj niezmienność - nigdy nie modyfikuj stanu bezpośrednio

  5. Dokumentuj intencje - używaj opisowych nazw akcji i selektorów

  6. Używaj selektorów - do pobierania pochodnych danych

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

Podsumowanie

Globalne zarządzanie stanem jest kluczowym aspektem budowania skalowalnych aplikacji React. W tym module poznaliśmy różne podejścia:

  1. Context API + useReducer - wbudowane rozwiązanie React, dobre dla mniejszych i średnich aplikacji

  2. Podział na wiele kontekstów - sposób na organizację globalnego stanu według domen

  3. Optymalizacja wydajności - techniki minimalizacji przerenderowywania

  4. Redux - tradycyjna biblioteka z ustrukturyzowanym podejściem do zarządzania stanem

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

Ir a CodeWorlds