We use cookies to enhance your experience on the site
CodeWorlds

Global state management

As a React application grows, state management becomes an increasingly complex challenge. In small applications, local component state and props are sufficient, but as things scale, we need more advanced solutions for managing global application state.

In this module we'll look at different approaches to global state management in React, from built-in mechanisms to popular external libraries.

Challenges of global state

Before we dive into solutions, let's understand why global state management can be challenging:

  1. Data sharing - components in different parts of the component tree may need access to the same data
  2. Synchronization - changes in one place should be visible everywhere the data is used
  3. Predictability - state updates should be predictable and easy to trace
  4. Performance - unnecessary re-rendering should be minimized
  5. Complexity - state logic can be complicated, with many related changes

Context API + useReducer as a basic solution

As we learned in previous modules, combining Context API with useReducer provides a solid foundation for global state management:

1import React, { createContext, useReducer, useContext } from 'react';
2
3// Initial state
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// Context
33const AppStateContext = createContext();
34
35// Provider
36function AppStateProvider({ children }) {
37  const [state, dispatch] = useReducer(appReducer, initialState);
38
39  // We can also add helper functions
40  const login = async (credentials) => {
41    try {
42      dispatch({ type: 'LOGIN_START' });
43      // API call for login
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  // Context value
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 for using context
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// Usage in application
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>Welcome, {state.user.name}</span>
105          <button onClick={() => dispatch({ type: 'LOGOUT' })}>Logout</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>Loading missions...</p>;
122  if (state.error) return <p>Error: {state.error}</p>;
123
124  return (
125    <div>
126      <h2>Available missions</h2>
127      <ul>
128        {state.missions.map(mission => (
129          <li key={mission.id}>
130            {mission.name}
131            <button onClick={() => setActiveMission(mission)}>
132              Select
133            </button>
134          </li>
135        ))}
136      </ul>
137    </div>
138  );
139}

This solution has several advantages:

  • Uses built-in React mechanisms
  • Requires no external dependencies
  • Provides good structural foundations for global state

However, as the application grows, this solution may encounter limitations related to performance and code organization.

Scaling applications with multiple contexts

For larger applications, it's recommended to split global state into smaller, independent contexts by domain:

1// Authentication context
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// Missions context
35const MissionsContext = createContext();
36
37function MissionsProvider({ children }) {
38  const [missions, setMissions] = useReducer(missionsReducer, []);
39  const [activeMission, setActiveMission] = useState(null);
40  // rest of the logic...
41
42  return (
43    <MissionsContext.Provider value={{ missions, activeMission, /*...*/ }}>
44      {children}
45    </MissionsContext.Provider>
46  );
47}
48
49// UI context
50const UIContext = createContext();
51
52function UIProvider({ children }) {
53  const [darkMode, setDarkMode] = useState(false);
54  const [sidebarOpen, setSidebarOpen] = useState(true);
55  // rest of the logic...
56
57  return (
58    <UIContext.Provider value={{ darkMode, toggleDarkMode: () => setDarkMode(!darkMode), /*...*/ }}>
59      {children}
60    </UIContext.Provider>
61  );
62}
63
64// Combining all providers
65function AppProviders({ children }) {
66  return (
67    <AuthProvider>
68      <MissionsProvider>
69        <UIProvider>
70          {children}
71        </UIProvider>
72      </MissionsProvider>
73    </AuthProvider>
74  );
75}

Advantages of this approach:

  • Better separation of concerns
  • Smaller contexts = fewer unnecessary re-renders
  • Easier testing of individual modules
  • Components can use only the contexts they need

Performance optimization with useMemo and memo

One of the challenges when using Context API is performance - every change in the context value causes a re-render of all components that use that context. Here are some optimization techniques:

1function OptimizedProvider({ children }) {
2  const [state, dispatch] = useReducer(reducer, initialState);
3
4  // Memoize the context value
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// Optimizing consuming components
17const MissionItem = memo(function MissionItem({ mission, onSelect }) {
18  return (
19    <li>
20      {mission.name}
21      <button onClick={() => onSelect(mission)}>Select</button>
22    </li>
23  );
24});
25
26function MissionList() {
27  const { missions, selectMission } = useMissions();
28
29  // Memoize callback
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}

Introduction to Redux Toolkit

Redux is a popular state management library that offers a more structured approach to global state. Today, modern Redux means Redux Toolkit (RTK) - the official, recommended toolset that eliminates most of the boilerplate. Instead of manually writing reducers with a

switch
statement, we use
createSlice
, and instead of
createStore
-
configureStore
. Here are the basics of Redux Toolkit with React:

1import { configureStore, createSlice } from '@reduxjs/toolkit';
2import { Provider, useSelector, useDispatch } from 'react-redux';
3
4// Slice = a piece of state + reducers + actions in one place
5const spaceAppSlice = createSlice({
6  name: 'spaceApp',
7  initialState: {
8    missions: [],
9    user: null
10  },
11  reducers: {
12    // Thanks to Immer we can "mutate" state - RTK does it safely and immutably
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 automatically generates action creators
23export const { setUser, setMissions } = spaceAppSlice.actions;
24
25// Store - configureStore adds DevTools and middleware for you
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// Usage in components
40function UserProfile() {
41  // Selecting part of the state
42  const user = useSelector(state => state.user);
43  const dispatch = useDispatch();
44
45  if (!user) return <p>User not logged in</p>;
46
47  return (
48    <div>
49      <h2>{user.name}</h2>
50      <button onClick={() => dispatch(setUser(null))}>
51        Logout
52      </button>
53    </div>
54  );
55}

Historical note: Classic Redux used

createStore
and manual reducers with a
switch
statement. That approach (
import { createStore } from 'redux'
) is now deprecated - the official documentation recommends Redux Toolkit. That is why we learn the modern way from the start.

Code organization in Redux Toolkit

In larger applications, each piece of state is a separate slice.

configureStore
combines them automatically - you just pass a map of reducers (no manual
combineReducers
needed):

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 combines reducers itself - no 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// Asynchronous operations are handled by 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 react to the thunk states (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;

Modern alternatives

Many alternative state management libraries have emerged in the React ecosystem that aim to simplify the process and reduce boilerplate code. Here are some popular options:

1. Zustand

Zustand is a minimalist library that combines the simplicity of a hook with the power of a store:

1import create from 'zustand';
2
3// Creating a 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// Usage in a component
23function MissionPanel() {
24  // Selectively fetching data from the 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>Loading...</p>;
34
35  return (
36    <div>
37      <h2>Missions</h2>
38      <ul>
39        {missions.map(mission => (
40          <MissionItem key={mission.id} mission={mission} />
41        ))}
42      </ul>
43    </div>
44  );
45}

2. Recoil

Recoil is a library created by Facebook that introduces the concepts of "atoms" and "selectors":

1import { atom, selector, useRecoilState, useRecoilValue } from 'recoil';
2
3// Defining atoms (smallest units of state)
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// Selector (derived state)
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// Usage in components
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        All
43      </button>
44      <button
45        className={filter === 'active' ? 'active' : ''}
46        onClick={() => setFilter('active')}
47      >
48        Active
49      </button>
50      <button
51        className={filter === 'completed' ? 'active' : ''}
52        onClick={() => setFilter('completed')}
53      >
54        Completed
55      </button>
56    </div>
57  );
58}
59
60function MissionsList() {
61  // Using the filtered value
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, similar to Recoil, uses an atom-based approach but with a more minimalist API:

1import { atom, useAtom } from 'jotai';
2
3// Defining atoms
4const userAtom = atom(null);
5const missionsAtom = atom([]);
6const missionFilterAtom = atom('all');
7
8// Derived atom
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// Component
26function MissionDashboard() {
27  const [missions, setMissions] = useAtom(missionsAtom);
28  const [filter, setFilter] = useAtom(missionFilterAtom);
29  const [filteredMissions] = useAtom(filteredMissionsAtom);
30
31  // Component logic...
32
33  return (
34    <div>
35      {/* UI */}
36    </div>
37  );
38}

Choosing the right solution

How do you choose the best state management solution for your application? Here are some guidelines:

  1. Application size:

    • Small applications: useState + useReducer + Context API
    • Medium applications: Zustand or Jotai
    • Large applications: Redux (with Redux Toolkit) or Recoil
  2. State complexity:

    • Simple state: useState
    • Moderately complex state: useReducer + Context API
    • Complex state with relationships: Redux or Recoil
  3. Performance requirements:

    • If rendering performance is critical, consider libraries that provide granular reactivity (Recoil, Jotai)
  4. Team experience:

    • If the team knows Redux, it may be worth sticking with it
    • For new projects, simpler APIs like Zustand or Jotai can speed up development

Best practices

Regardless of the chosen solution, here are some general best practices for global state management:

  1. Minimize global state - not everything needs to be global; use local state for component-specific data

  2. Split state by logical domains - group related data and logic together

  3. Normalize data - avoid duplication and deep nesting

  4. Maintain immutability - never modify state directly

  5. Document intentions - use descriptive names for actions and selectors

  6. Use selectors - for retrieving derived data

  7. Keep business logic out of components - in actions, reducers, or dedicated hooks

1// Example: Hook with business logic
2function useMissionControl() {
3  const { state, dispatch } = useAppState();
4
5  const launchMission = useCallback(async (missionId) => {
6    // Check business conditions
7    if (!state.user || state.user.role !== 'commander') {
8      throw new Error('Only the commander can start a mission');
9    }
10
11    if (state.weather === 'storm') {
12      throw new Error('Cannot launch during a storm');
13    }
14
15    // Business logic
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}

Summary

Global state management is a key aspect of building scalable React applications. In this module we learned about different approaches:

  1. Context API + useReducer - React's built-in solution, good for small and medium applications

  2. Splitting into multiple contexts - a way to organize global state by domain

  3. Performance optimization - techniques for minimizing re-renders

  4. Redux - a traditional library with a structured approach to state management

  5. Modern alternatives - Zustand, Recoil, Jotai offering simpler APIs and less code

Choosing the right solution depends on the application size, state complexity, and team preferences. Regardless of the choice, following best practices will help maintain clean, predictable, and performant code.

Go to CodeWorlds