•7 min read

Replacing Redux: Advanced State Management with React Context and useReducer

Replacing Redux: Advanced State Management with React Context and useReducer

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.

Audio Briefing
0:00 / 0:00

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.

Advertisement

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.

Advertisement

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

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement