Replacing Redux: Advanced State Management with React Context and useReducer

Table of Contents
For years, Redux was the undisputed king of React state management. It provided a predictable state container, a robust ecosystem of middleware, and a time-traveling debugger that made developers swoon. However, as React evolved, the boilerplate and complexity of Redux began to feel like a burden rather than a blessing for many applications.
With the introduction of Hooks, specifically useContext and useReducer, React offered built-in primitives that could ostensibly replace Redux for a significant portion of use cases. While many tutorials—such as those by Robin Wieruch—have done an excellent job introducing these concepts, they often stop short of addressing the critical performance bottlenecks, architectural challenges, and middleware requirements that arise at scale.
In this comprehensive, highly technical guide, we will move beyond the basics. We will construct an optimized, scalable state management solution using Context and useReducer, focusing on rendering performance, state slicing, custom middleware patterns, and how to rival Redux in both Developer Experience (DX) and runtime efficiency.
The Problem with Naive Context and useReducer
The most common approach to replacing Redux is wrapping the entire application (or a large subsection) in a single Context Provider, passing down a state object and a dispatch function.
// Naive implementation
import { createContext, useReducer } from 'react';
const AppContext = createContext();
const AppProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
While this works perfectly for small applications, it introduces a massive performance flaw at scale: Context Propagation.
Whenever the state changes, every component that consumes AppContext via useContext(AppContext) will re-render. This happens regardless of whether the component actually uses the specific slice of state that was updated. Redux avoids this pitfall natively via its useSelector hook, which subscribes only to specific parts of the store and aggressively bails out of rendering if the selected slice hasn't changed.
To truly replace Redux in a modern, high-performance application, we must solve the re-render problem.
Architectural Optimization 1: Splitting State and Dispatch Contexts
The first step in our optimization journey is splitting the state and dispatch functions into entirely separate contexts. The dispatch function returned by useReducer is stable—its memory reference never changes across re-renders. By isolating it in its own provider, components that only need to trigger actions (like a "Submit" or "Toggle" button) can consume the dispatch context without re-rendering when the state changes.
import React, { createContext, useReducer } from 'react';
const StateContext = createContext(null);
const DispatchContext = createContext(null);
export const AppStateProvider = ({ children }) => {
const [state, dispatch] = useReducer(rootReducer, initialState);
return (
<DispatchContext.Provider value={dispatch}>
<StateContext.Provider value={state}>
{children}
</StateContext.Provider>
</DispatchContext.Provider>
);
};
Now, a ThemeToggleButton can consume DispatchContext to fire a TOGGLE_THEME action. Since the reference to dispatch never changes, this component will never pointlessly re-render when the global state updates.
Architectural Optimization 2: Domain-Driven Slicing
Even with dispatch separated, the StateContext still holds the entire global state. If a user updates their profile picture, a deeply nested SidebarMenu that consumes the StateContext merely to read the user's role will still re-render.
Instead of a monolithic store, we should slice our state by domain. React allows us to compose multiple independent providers elegantly.
export const GlobalProvider = ({ children }) => (
<AuthProvider>
<ThemeProvider>
<ShoppingCartProvider>
{children}
</ShoppingCartProvider>
</ThemeProvider>
</AuthProvider>
);
By siloing state, we ensure that an update to the shopping cart only triggers re-renders in components explicitly consuming the ShoppingCartContext. This closely mirrors Redux slices, but leverages React's native tree architecture.
Architectural Optimization 3: Context Selectors via Memoization
Even with domain slicing, a complex domain (e.g., UserContext) might contain fields like permissions, preferences, and activityLog. If a component only needs permissions, it shouldn't re-render when activityLog changes.
React's Context API doesn't natively support selectors (though useContextSelector proposals are in the works). However, we can achieve this using Higher-Order Components (HOCs) and React.memo to mimic Redux's exact rendering behavior.
import React, { useContext, memo } from 'react';
// The presentation component is memoized
const UserProfile = memo(({ username, role }) => {
console.log("UserProfile rendered!");
return (
<div className="profile-card">
<h1>{username}</h1>
<span>{role}</span>
</div>
);
});
// The wrapper component consumes context
const UserProfileContainer = () => {
const { username, role, lastLogin } = useContext(UserContext);
// lastLogin updates frequently, but UserProfile only receives username and role.
// Because UserProfile is wrapped in memo, it will bail out of the reconciliation phase!
return <UserProfile username={username} role={role} />;
};
This pattern acts as a pseudo-selector. The UserProfileContainer re-renders every time UserContext changes, which is extremely fast. However, because it returns a memoized <UserProfile />, React halts the render phase before it reaches the expensive Virtual DOM diffing operations.
Replicating Middleware: The Enhanced useReducer
One of Redux's greatest strengths is its middleware ecosystem—allowing you to intercept actions for logging, analytics, or asynchronous operations. We can replicate this by wrapping React's native useReducer.
Let's build a custom hook that adds logging and thunk capabilities to useReducer:
import { useReducer, useCallback, useRef } from 'react';
const useEnhancedReducer = (reducer, initialState, middlewares = []) => {
const [state, dispatch] = useReducer(reducer, initialState);
const stateRef = useRef(state);
// Keep state ref updated for middleware access
stateRef.current = state;
const enhancedDispatch = useCallback(
(action) => {
// Allow thunks (functions as actions)
if (typeof action === 'function') {
return action(enhancedDispatch, () => stateRef.current);
}
// Run pre-dispatch middlewares
middlewares.forEach((mw) => mw(action, stateRef.current));
// Native dispatch
dispatch(action);
},
[middlewares]
);
return [state, enhancedDispatch];
};
With this enhanced hook, you can pass custom middleware exactly like Redux:
const loggerMiddleware = (action, state) => {
console.group(`Action: ${action.type}`);
console.log('Previous State:', state);
console.log('Payload:', action.payload);
console.groupEnd();
};
const [state, dispatch] = useEnhancedReducer(
rootReducer,
initialState,
[loggerMiddleware]
);
Managing Asynchronous Logic Elegantly
Redux Thunk and Redux Saga are famously used to handle side effects. How do we replicate this elegantly with Context, assuming we don't want to build a custom middleware runtime?
The answer lies in abstracting asynchronous operations into custom hooks that wrap our dispatch function.
export const useAuthActions = () => {
const dispatch = useContext(DispatchContext);
const loginUser = async (credentials) => {
dispatch({ type: 'LOGIN_REQUEST' });
try {
const response = await api.login(credentials);
dispatch({ type: 'LOGIN_SUCCESS', payload: response.user });
} catch (error) {
dispatch({ type: 'LOGIN_FAILURE', payload: error.message });
}
};
return { loginUser };
};
This pattern encapsulates business logic away from UI components. Your presentation layer simply calls loginUser(credentials), keeping the component completely agnostic to the API implementation. It provides the exact same decoupling benefits as Redux Thunks, but with better TypeScript inference and zero middleware setup.
Immutability and Immer
A common pain point with useReducer is updating deeply nested objects. Redux Toolkit solves this by integrating Immer out of the box. You can easily achieve the same DevX by wrapping your reducer with Immer's produce.
import { produce } from 'immer';
const userReducer = produce((draft, action) => {
switch (action.type) {
case 'UPDATE_PROFILE':
// Direct mutation! Immer handles the immutability under the hood.
draft.profile.address.city = action.payload;
break;
}
});
This single addition bridges one of the largest ergonomic gaps between native React state and Redux Toolkit.
Conclusion
Replacing Redux with React Context and useReducer is a powerful architectural choice, but it demands respect. Naive implementations inevitably lead to sluggish interfaces and render storms as the application grows.
By splitting your state and dispatch contexts, siloing state into domain-specific providers, utilizing advanced memoization techniques for selector-like behavior, and embracing custom hooks for middleware and async logic, you can construct a state management architecture that is as performant as Redux.
More importantly, it embraces idiomatic React patterns, keeping your dependency tree light, and making modern frontend development deeply enjoyable. React has given us the primitives; it is up to us to architect them elegantly.
You Might Also Like
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

State Management in React 2026: Beyond Redux
Comprehensive guide to React state management in 2026: comparing React 19 actions, TanStack Query server state, Zustand, Jotai, and Signals.
Read more
Mastering SVG in React 19: Performance, Dynamic currentColor & Bundle Optimization
Stop shipping 800kB of unused icon bloat. Master SVGs in React 19 with dynamic currentColor theming, SVG sprite sheets, forwardRef interfaces, and zero-runtime overhead.
Read more
The Paradigm Shift of React Server Components
Explore how React Server Components (RSC) fundamentally change the way we build React applications, offering smaller bundle sizes, simplified data fetching, and improved performance.
Read more