Architecting React State: From Context API to Redux Toolkit and Modern Alternatives
Effective state management is the backbone of scalable React applications. This guide explores the Context API for dependency injection, optimizes performance to avoid re-renders, and provides a critical comparison of the ecosystem's leading libraries—including Redux Toolkit (RTK), RTK Query, and atomic state solutions like Jotai and Recoil.
React Context
Context overview
Context provides a way to pass data through the component tree without having to pass props manually at every level, solving the "prop drilling" problem. It's ideal for global data like themes, authentication, or language preferences.
Without Context (Prop Drilling): With Context:
┌─────────────────────┐ ┌─────────────────────┐
│ App (theme) │ │ App │
│ └─▶ Header │ │ └─▶ Provider │
│ └─▶ Nav │ │ └─▶ Header │
│ └─▶ …│ │ └─▶ …│
│ │ │ │
│ (pass theme 5x) │ │ (consume directly) │
└─────────────────────┘ └─────────────────────┘
createContext
createContext creates a context object with a Provider and Consumer, optionally taking a default value used when no Provider is found above in the tree. The default value is useful for testing components in isolation.
import { createContext } from 'react'; // Create context with default value const ThemeContext = createContext('light'); const UserContext = createContext(null); const LocaleContext = createContext({ language: 'en', setLanguage: () => {} }); // Export for use in other components export { ThemeContext, UserContext, LocaleContext };
Context.Provider
The Provider component accepts a value prop and makes that value available to all descendants, re-rendering consumers whenever the value changes. Multiple Providers of the same context can be nested, with the closest one taking precedence.
const ThemeContext = createContext('light'); function App() { const [theme, setTheme] = useState('dark'); const [user, setUser] = useState(null); return ( <ThemeContext.Provider value={theme}> <UserContext.Provider value={{ user, setUser }}> <Layout> <Router /> </Layout> </UserContext.Provider> </ThemeContext.Provider> ); } // Nested providers (inner overrides outer) <ThemeContext.Provider value="light"> <Header /> {/* Uses 'light' */} <ThemeContext.Provider value="dark"> <Sidebar /> {/* Uses 'dark' */} </ThemeContext.Provider> </ThemeContext.Provider>
Context.Consumer
The Consumer component subscribes to context changes using a render prop pattern, receiving the current context value and rendering based on it. This is the legacy approach; useContext hook is now preferred for function components.
const ThemeContext = createContext('light'); // Consumer pattern (legacy) function ThemedButton() { return ( <ThemeContext.Consumer> {theme => ( <button className={`btn-${theme}`}> Themed Button </button> )} </ThemeContext.Consumer> ); } // Nested consumers <ThemeContext.Consumer> {theme => ( <UserContext.Consumer> {user => ( <div className={theme}>{user.name}</div> )} </UserContext.Consumer> )} </ThemeContext.Consumer>
useContext hook
useContext is a hook that accepts a context object and returns the current context value, making context consumption much cleaner than the Consumer pattern. The component will re-render when the context value changes.
const ThemeContext = createContext('light'); const UserContext = createContext(null); function UserProfile() { // Much cleaner than Consumer pattern const theme = useContext(ThemeContext); const { user, logout } = useContext(UserContext); if (!user) { return <div className={theme}>Please log in</div>; } return ( <div className={`profile-${theme}`}> <h2>{user.name}</h2> <p>{user.email}</p> <button onClick={logout}>Logout</button> </div> ); }
Multiple contexts
When a component needs multiple context values, you can use useContext multiple times or combine related contexts into a single context object. Group related data to avoid excessive context subscriptions.
// Separate contexts const ThemeContext = createContext(); const LocaleContext = createContext(); const AuthContext = createContext(); function Dashboard() { const theme = useContext(ThemeContext); const locale = useContext(LocaleContext); const auth = useContext(AuthContext); // ... } // Combined context (for related data) const AppContext = createContext(); function AppProvider({ children }) { const [theme, setTheme] = useState('light'); const [locale, setLocale] = useState('en'); const value = useMemo(() => ({ theme, setTheme, locale, setLocale }), [theme, locale]); return ( <AppContext.Provider value={value}> {children} </AppContext.Provider> ); }
Context composition
Context composition involves organizing multiple providers and creating a pattern for combining them cleanly, avoiding the "provider hell" of deeply nested providers. A common pattern is creating a single composed provider component.
// Compose multiple providers function AppProviders({ children }) { return ( <ThemeProvider> <AuthProvider> <LocaleProvider> <NotificationProvider> {children} </NotificationProvider> </LocaleProvider> </AuthProvider> </ThemeProvider> ); } // Or create a compose utility function composeProviders(...providers) { return ({ children }) => providers.reduceRight( (acc, Provider) => <Provider>{acc}</Provider>, children ); } const Providers = composeProviders( ThemeProvider, AuthProvider, LocaleProvider ); // Usage <Providers><App /></Providers>
Context performance
Context causes all consumers to re-render when the value changes, which can cause performance issues with frequently changing values. Optimize by splitting contexts, memoizing values, and using state management libraries for highly dynamic data.
// ❌ Problem: All consumers re-render on any change const AppContext = createContext(); <AppContext.Provider value={{ user, theme, cart, notifications }}> // ✅ Solution 1: Split contexts by update frequency <UserContext.Provider value={user}> <ThemeContext.Provider value={theme}> <CartContext.Provider value={cart}> // ✅ Solution 2: Memoize context value function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const value = useMemo(() => ({ theme, setTheme }), [theme]); return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); } // ✅ Solution 3: Separate state and dispatch contexts const StateContext = createContext(); const DispatchContext = createContext();
Context vs prop drilling
Use props for simple parent-child communication and context for deeply nested or cross-cutting concerns. Context adds complexity and makes components less reusable, so prefer composition and props when practical.
Use Props When: Use Context When:
┌────────────────────────┐ ┌────────────────────────┐
│ • 1-2 levels deep │ │ • Many levels deep │
│ • Direct parent-child │ │ • Cross-cutting concern│
│ • Component reuse │ │ • Global state (theme) │
│ • Explicit data flow │ │ • Avoid prop threading │
└────────────────────────┘ └────────────────────────┘
// Often, composition solves "prop drilling" without context
function Page() {
const user = useUser();
// Pass the component, not data
return <Layout header={<Header user={user} />} />;
}
Theme context pattern
The theme context pattern provides theme values (colors, fonts, spacing) and a toggle function throughout the app, often combined with CSS variables or styled-components for styling. It typically includes system preference detection.
const ThemeContext = createContext(); function ThemeProvider({ children }) { const [theme, setTheme] = useState(() => { const save ```jsx const ThemeContext = createContext(); function ThemeProvider({ children }) { const [theme, setTheme] = useState(() => { const saved = localStorage.getItem('theme'); if (saved) return saved; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }); useEffect(() => { localStorage.setItem('theme', theme); document.documentElement.setAttribute('data-theme', theme); }, [theme]); const toggleTheme = useCallback(() => { setTheme(t => t === 'light' ? 'dark' : 'light'); }, []); const value = useMemo(() => ({ theme, toggleTheme, isDark: theme === 'dark' }), [theme, toggleTheme]); return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); } // Custom hook for consuming function useTheme() { const context = useContext(ThemeContext); if (!context) { throw new Error('useTheme must be used within ThemeProvider'); } return context; } // Usage function ThemeToggle() { const { theme, toggleTheme, isDark } = useTheme(); return ( <button onClick={toggleTheme}> {isDark ? '☀️' : '🌙'} </button> ); }
Auth context pattern
The auth context pattern centralizes authentication state and methods (login, logout, register) making them available throughout the app. It typically handles token storage, user data, and protected route logic.
const AuthContext = createContext(); function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { // Check for existing session on mount const token = localStorage.getItem('token'); if (token) { api.getUser(token) .then(setUser) .catch(() => localStorage.removeItem('token')) .finally(() => setLoading(false)); } else { setLoading(false); } }, []); const login = useCallback(async (email, password) => { const { user, token } = await api.login(email, password); localStorage.setItem('token', token); setUser(user); }, []); const logout = useCallback(() => { localStorage.removeItem('token'); setUser(null); }, []); const value = useMemo(() => ({ user, isAuthenticated: !!user, loading, login, logout }), [user, loading, login, logout]); return ( <AuthContext.Provider value={value}> {children} </AuthContext.Provider> ); } // Custom hook with error handling function useAuth() { const context = useContext(AuthContext); if (!context) { throw new Error('useAuth must be used within AuthProvider'); } return context; } // Protected route component function ProtectedRoute({ children }) { const { isAuthenticated, loading } = useAuth(); if (loading) return <LoadingSpinner />; if (!isAuthenticated) return <Navigate to="/login" />; return children; } // Usage in components function Navbar() { const { user, logout, isAuthenticated } = useAuth(); return ( <nav> {isAuthenticated ? ( <> <span>Welcome, {user.name}</span> <button onClick={logout}>Logout</button> </> ) : ( <Link to="/login">Login</Link> )} </nav> ); }
State Management
Local State
Local state is component-scoped state managed with useState, ideal for UI state that doesn't need to be shared; it's the simplest and most performant option when data doesn't need to leave the component.
const [count, setCount] = useState(0); const [isOpen, setIsOpen] = useState(false);
Lifting State Up
When multiple components need the same state, lift it to their closest common ancestor and pass it down via props; this maintains a single source of truth while enabling sharing.
Before: After:
┌───┐ ┌───┐ ┌─────────┐
│ A │ │ B │ │ Parent │ ← state lives here
│ s │ │ s │ │ state │
└───┘ └───┘ └────┬────┘
┌────┴────┐
▼ ▼
┌───┐ ┌───┐
│ A │ │ B │
└───┘ └───┘
Context for State
React Context provides a way to pass data through the component tree without prop drilling; combine with useState or useReducer for a lightweight global state solution.
const ThemeContext = createContext(); const ThemeProvider = ({ children }) => { const [theme, setTheme] = useState('light'); return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>; }; const { theme } = useContext(ThemeContext);
useState Patterns
Advanced useState patterns include lazy initialization, functional updates for state based on previous values, and using objects or multiple useState calls for related values.
// Lazy initialization (expensive computation) const [data, setData] = useState(() => computeExpensiveValue()); // Functional update (safe for async) setCount(prev => prev + 1); // Multiple vs object state const [name, setName] = useState(''); const [age, setAge] = useState(0); // vs const [user, setUser] = useState({ name: '', age: 0 });
useReducer Patterns
useReducer is ideal for complex state logic with multiple sub-values or when next state depends on previous; it's like Redux in a single component with predictable state transitions.
const reducer = (state, action) => { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: 0 }; default: throw new Error(); } }; const [state, dispatch] = useReducer(reducer, { count: 0 }); dispatch({ type: 'increment' });
Redux
Redux is a predictable state container implementing unidirectional data flow; all state lives in a single store, changes happen through pure reducer functions triggered by dispatched actions.
┌─────────────────────────────────────────┐
│ Redux Flow │
│ ┌──────┐ dispatch ┌──────────┐ │
│ │ View │ ──────────▶ │ Actions │ │
│ └──┬───┘ └────┬─────┘ │
│ ▲ ▼ │
│ │ ┌──────────┐ ┌──────────┐ │
│ └────│ Store │◀─│ Reducers │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────┘
Redux Toolkit
Redux Toolkit (RTK) is the official, opinionated toolset for Redux; it simplifies store setup, reduces boilerplate with createSlice, includes Immer for "mutative" updates, and is now the standard way to write Redux.
import { configureStore, createSlice } from '@reduxjs/toolkit'; const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { increment: state => { state.value += 1; }, // Immer handles immutability }, }); const store = configureStore({ reducer: { counter: counterSlice.reducer } });
Store Creation
The store holds the entire application state tree; with RTK's configureStore, you get good defaults including Redux DevTools and thunk middleware automatically configured.
import { configureStore } from '@reduxjs/toolkit'; const store = configureStore({ reducer: { users: usersReducer, posts: postsReducer, }, middleware: (getDefault) => getDefault().concat(customMiddleware), devTools: process.env.NODE_ENV !== 'production', });
Slices
A slice is a collection of Redux logic (reducer + actions) for a single feature; createSlice generates action creators and action types automatically from the reducer functions you define.
const userSlice = createSlice({ name: 'user', initialState: { profile: null, loading: false }, reducers: { setUser: (state, action) => { state.profile = action.payload; }, clearUser: (state) => { state.profile = null; }, }, }); export const { setUser, clearUser } = userSlice.actions; export default userSlice.reducer;
Reducers
Reducers are pure functions that take current state and an action, returning new state; with RTK and Immer, you can write "mutating" logic that gets converted to immutable updates.
// Traditional (immutable) const reducer = (state, action) => ({ ...state, value: state.value + 1 }); // RTK with Immer (looks mutating, is immutable) const reducer = (state, action) => { state.value += 1; };
Actions
Actions are plain objects with a type field describing what happened and optional payload with data; they're the only way to trigger state changes in Redux.
// Action object { type: 'users/addUser', payload: { id: 1, name: 'John' } } // Action creator (auto-generated by createSlice) const action = addUser({ id: 1, name: 'John' });
Action Creators
Action creators are functions that create and return action objects; RTK's createSlice generates them automatically, eliminating manual action type constants and creator functions.
// Manual (old way) const ADD_USER = 'ADD_USER'; const addUser = (user) => ({ type: ADD_USER, payload: user }); // RTK (automatic) const { addUser } = userSlice.actions; // Generated from reducers
Dispatch
Dispatch sends actions to the store to trigger reducer execution; it's the only way to update Redux state and is accessed via useDispatch hook in React components.
const dispatch = useDispatch(); dispatch(increment()); dispatch(setUser({ name: 'John' })); dispatch(fetchUsers()); // thunk action
useSelector
useSelector extracts data from the Redux store state using a selector function; it subscribes to the store and re-renders only when the selected value changes.
const user = useSelector(state => state.user.profile); const todos = useSelector(state => state.todos.filter(t => !t.completed)); // With Reselect for memoization const selectCompletedTodos = createSelector( state => state.todos, todos => todos.filter(t => t.completed) );
useDispatch
useDispatch returns the store's dispatch function to send actions from components; often wrapped in useCallback when passed to child components to prevent unnecessary re-renders.
const dispatch = useDispatch(); const handleClick = useCallback(() => { dispatch(increment()); }, [dispatch]);
Connect (Legacy)
connect is the legacy Higher-Order Component API for connecting React components to Redux; while still supported, hooks (useSelector/useDispatch) are now preferred for cleaner code.
// Legacy approach const mapStateToProps = state => ({ count: state.counter.value }); const mapDispatchToProps = { increment, decrement }; export default connect(mapStateToProps, mapDispatchToProps)(Counter); // Modern approach (preferred) const count = useSelector(state => state.counter.value); const dispatch = useDispatch();
Redux Middleware
Middleware intercepts dispatched actions before they reach the reducer, enabling logging, async operations, or action transformation; they compose a pipeline around dispatch.
dispatch(action)
│
▼
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Logger │ → │ Thunk │ → │ Custom │ → │ Reducer │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
const loggerMiddleware = store => next => action => { console.log('Dispatching:', action); const result = next(action); console.log('Next state:', store.getState()); return result; };
Redux Thunk
Redux Thunk middleware allows action creators to return functions instead of objects, enabling async logic like API calls; the function receives dispatch and getState as arguments.
const fetchUsers = () => async (dispatch, getState) => { dispatch(setLoading(true)); try { const response = await api.getUsers(); dispatch(setUsers(response.data)); } catch (error) { dispatch(setError(error.message)); } finally { dispatch(setLoading(false)); } }; dispatch(fetchUsers());
Redux Saga
Redux Saga uses ES6 generators for complex async flows; it's more powerful than thunk for scenarios like race conditions, cancellation, debouncing, and complex sequencing.
import { takeEvery, call, put } from 'redux-saga/effects'; function* fetchUserSaga(action) { try { const user = yield call(api.fetchUser, action.payload); yield put(fetchUserSuccess(user)); } catch (e) { yield put(fetchUserFailed(e.message)); } } function* rootSaga() { yield takeEvery('FETCH_USER_REQUEST', fetchUserSaga); }
RTK Query
RTK Query is a powerful data fetching and caching solution built into Redux Toolkit; it generates hooks for API endpoints, handles caching, loading states, and updates automatically.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; const api = createApi({ baseQuery: fetchBaseQuery({ baseUrl: '/api' }), endpoints: (builder) => ({ getUsers: builder.query({ query: () => '/users' }), addUser: builder.mutation({ query: (user) => ({ url: '/users', method: 'POST', body: user }) }), }), }); export const { useGetUsersQuery, useAddUserMutation } = api; // Usage: const { data, isLoading } = useGetUsersQuery();
Zustand
Zustand is a minimal, fast state management library using hooks; it has no boilerplate, no providers needed, and the API is extremely simple while supporting middleware and devtools.
import { create } from 'zustand'; const useStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 }), })); // Usage in component const { count, increment } = useStore();
Recoil
Recoil is Facebook's experimental state management library using atoms (units of state) and selectors (derived state); it integrates naturally with React's concurrent features.
import { atom, selector, useRecoilState, useRecoilValue } from 'recoil'; const countAtom = atom({ key: 'count', default: 0 }); const doubledSelector = selector({ key: 'doubled', get: ({ get }) => get(countAtom) * 2, }); const [count, setCount] = useRecoilState(countAtom); const doubled = useRecoilValue(doubledSelector);
Jotai
Jotai is a primitive and flexible atomic state library inspired by Recoil but simpler; atoms are the minimal unit of state, and derived atoms compose elegantly without selectors.
import { atom, useAtom } from 'jotai'; const countAtom = atom(0); const doubledAtom = atom((get) => get(countAtom) * 2); const [count, setCount] = useAtom(countAtom); const [doubled] = useAtom(doubledAtom);
MobX
MobX uses observable state and automatic tracking; you mutate state directly, and MobX automatically updates any component observing that state—a more OOP approach compared to Redux.
import { makeAutoObservable } from 'mobx'; import { observer } from 'mobx-react-lite'; class Store { count = 0; constructor() { makeAutoObservable(this); } increment() { this.count++; } } const store = new Store(); const Counter = observer(() => ( <button onClick={() => store.increment()}>{store.count}</button> ));
Valtio
Valtio is a proxy-based state management library where you mutate state directly and it "just works"; it uses JavaScript Proxy to track changes and trigger re-renders automatically.
import { proxy, useSnapshot } from 'valtio'; const state = proxy({ count: 0, text: 'hello' }); // Mutate directly anywhere state.count++; // In component const snap = useSnapshot(state); return <div>{snap.count}</div>;
XState
XState implements finite state machines and statecharts for predictable, visual state logic; it excels at complex flows like multi-step forms, authentication, or any state with explicit transitions.
import { createMachine, assign } from 'xstate'; import { useMachine } from '@xstate/react'; const toggleMachine = createMachine({ initial: 'inactive', states: { inactive: { on: { TOGGLE: 'active' } }, active: { on: { TOGGLE: 'inactive' } }, }, }); const [state, send] = useMachine(toggleMachine); // state.value === 'inactive' | 'active' send('TOGGLE');
┌──────────┐ TOGGLE ┌──────────┐ │ inactive │ ───────▶ │ active │ └──────────┘ ◀─────── └──────────┘ TOGGLE