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.
Before we dive into solutions, let's understand why global state management can be challenging:
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:
However, as the application grows, this solution may encounter limitations related to performance and code organization.
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:
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}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
and manual reducers with acreateStorestatement. That approach (switch) is now deprecated - the official documentation recommends Redux Toolkit. That is why we learn the modern way from the start.import { createStore } from 'redux'
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;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:
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}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}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}How do you choose the best state management solution for your application? Here are some guidelines:
Application size:
State complexity:
Performance requirements:
Team experience:
Regardless of the chosen solution, here are some general best practices for global state management:
Minimize global state - not everything needs to be global; use local state for component-specific data
Split state by logical domains - group related data and logic together
Normalize data - avoid duplication and deep nesting
Maintain immutability - never modify state directly
Document intentions - use descriptive names for actions and selectors
Use selectors - for retrieving derived data
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}Global state management is a key aspect of building scalable React applications. In this module we learned about different approaches:
Context API + useReducer - React's built-in solution, good for small and medium applications
Splitting into multiple contexts - a way to organize global state by domain
Performance optimization - techniques for minimizing re-renders
Redux - a traditional library with a structured approach to state management
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.