Replacing Redux with the new React Context API

Managing state in a complex React application can quickly get unwieldy as you pass data through multiple levels of the component tree. Changing state in one part of the app can have ripple effects across many components. For the past few years, Redux has been the go-to solution for taming unruly state. By keeping state in a single global store and making changes through dispatched actions, Redux provides a strict, predictable state management pattern.

However, Redux comes with tradeoffs. There‘s a lot of boilerplate code involved in setting up actions and reducers. Connecting components to read from and dispatch to the store is also a bit tedious. And there‘s a steeper learning curve for developers to fully understand concepts like middleware, thunks, and time-travel debugging.

Thankfully, React‘s new Context API, introduced in version 16.3, gives us a more straightforward way to share state across our app without the complexity of Redux. Let‘s take a look at how it works.

The new React Context API

The new Context API allows us to share data to components further down the tree without explicitly passing props at each level. We do this by creating a context with a default value:

const MyContext = React.createContext(defaultValue);

To provide the context value to child components, we wrap them with the Provider component and pass in the current value:

<MyContext.Provider value={someValue}>
  <ChildComponent />
</MyContext.Provider>

Any child component can then access the context value by rendering a Consumer component and providing a render prop:

<MyContext.Consumer>
  {value => <div>The value is {value}</div>}
</MyContext.Consumer>

Here‘s a more concrete example of storing a user‘s info in context and accessing it further down the tree:

const UserContext = React.createContext({name: ‘‘, email: ‘‘});

function App() {
  const user = {name: ‘John‘, email: ‘[email protected]‘};
  return (
    <UserContext.Provider value={user}>
      <Dashboard />
    </UserContext.Provider>
  );
}

function Dashboard() {
  return (
    <div>

      <UserInfo />
    </div>
  );
}

function UserInfo() {
  return (
    <UserContext.Consumer>
      {user => (
        <div>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
        </div>
      )}
    </UserContext.Consumer>
  );
}

Notice how we didn‘t need to explicitly pass the user info as props to the Dashboard or UserInfo components. Any component that needs access to the context can simply render a Consumer.

Context vs Redux

So how does the new Context API stack up to Redux? Let‘s compare a few key aspects:

Setup and Boilerplate

  • Redux: Requires setting up actions, action creators, reducers, and a store. Components must be explicitly connected to read state and dispatch actions.
  • Context: Create a context with a default value, wrap child components in Provider, access value from Consumer. No separate actions/reducers needed.

Updating State

  • Redux: Dispatch actions to modify the store state. Reducers return a new state object.
  • Context: Set a new value on the Provider to update the context value. Changes trigger a re-render of Consumers.

Dev Tools

  • Redux: Robust developer tools for time-travel debugging, logging actions, and inspecting state changes.
  • Context: No special dev tools, but can be emulated with a custom Provider component or use of hooks (more on this later).

Overall, the Context API requires less setup and boilerplate compared to Redux. Updating state is more straightforward with Context, while Redux separates concerns with actions and reducers.

However, Redux still offers benefits like middleware support, richer dev tools, and enforcement of a strict unidirectional data flow. For simpler apps, Context may be enough, while Redux remains valuable for larger, more complex projects.

Migrating from Redux to Context

Let‘s walk through an example of migrating a small Redux app to the new Context API. Consider a simple counter app that lets us increment and decrement a number. Here‘s how it might look with Redux:

// counterActions.js
export const INCREMENT = ‘INCREMENT‘;
export const DECREMENT = ‘DECREMENT‘;

export function increment() {
  return { type: INCREMENT };
}

export function decrement() {
  return { type: DECREMENT };
}

// counterReducer.js
import { INCREMENT, DECREMENT } from ‘./counterActions‘;

const initialState = {
  count: 0
};

export default function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    default:
      return state;
  }
}

// store.js
import { createStore } from ‘redux‘;
import counterReducer from ‘./counterReducer‘;

const store = createStore(counterReducer);

export default store;

// Counter.js
import React from ‘react‘;
import { connect } from ‘react-redux‘;
import { increment, decrement } from ‘./counterActions‘;

function Counter({ count, increment, decrement }) {
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={decrement}>-</button>
      <button onClick={increment}>+</button>
    </div>
  );
}

function mapStateToProps(state) {
  return {
    count: state.count
  };
}

function mapDispatchToProps(dispatch) {
  return {
    increment: () => dispatch(increment()),
    decrement: () => dispatch(decrement())    
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(Counter);

// App.js
import React from ‘react‘;
import { Provider } from ‘react-redux‘;
import store from ‘./store‘;
import Counter from ‘./Counter‘;

function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

export default App;

There‘s quite a bit of code here between the actions, reducer, store setup, and connecting the Counter component with mapStateToProps and mapDispatchToProps.

Let‘s see how we can simplify this by using the Context API instead of Redux:

// CounterContext.js
import React from ‘react‘;

const CounterContext = React.createContext();

function counterReducer(state, action) {
  switch (action.type) {
    case ‘increment‘:
      return { count: state.count + 1 };
    case ‘decrement‘:
      return { count: state.count - 1 };
    default:
      throw new Error(`Unhandled action type: ${action.type}`);  
  }
}

function CounterProvider({ children }) {
  const [state, dispatch] = React.useReducer(counterReducer, { count: 0 });
  return (
    <CounterContext.Provider value={{state, dispatch}}>
      {children}
    </CounterContext.Provider>
  );
}

export { CounterProvider, CounterContext };

// Counter.js
import React from ‘react‘;
import { CounterContext } from ‘./CounterContext‘;

function Counter() {
  const { state, dispatch } = React.useContext(CounterContext);
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({type: ‘decrement‘})}>-</button>  
      <button onClick={() => dispatch({type: ‘increment‘})}>+</button>
    </div>
  );
}

export default Counter;

// App.js
import React from ‘react‘;
import { CounterProvider } from ‘./CounterContext‘;
import Counter from ‘./Counter‘; 

function App() {
  return (
    <CounterProvider>
      <Counter />  
    </CounterProvider>
  );
}

export default App;

With the Context API version, we‘ve eliminated the need for separate action creators, combining the actions and reducer into the CounterProvider component. The CounterContext wraps the state and dispatch in an object, which is passed as the context value.

The Counter component accesses the state and dispatch from the context using the useContext hook. It can dispatch actions directly, without the need for connect or mapDispatchToProps.

Overall, the Context API version is more concise and easier to follow. For a simple app like this, Context is a good fit. The tradeoff is we lose some of the benefits of Redux, like middleware and dev tools support. But for many apps, that may be a worthwhile tradeoff for the simpler architecture.

Caveats and Considerations

While the Context API is a powerful tool, there are some potential gotchas to be aware of.

Performance can be a concern if you have many Consumers that re-render frequently. Each time the Provider value changes, all Consumers will re-render by default. This can be mitigated by splitting your state across multiple contexts or optimizing your component tree.

Another issue is that values passed to a Provider must be memoized to prevent unnecessary re-renders. This means you need to be careful about creating new objects/arrays in the Provider value.

function ParentComponent() {
  const [count, setCount] = React.useState(0);

  // Don‘t do this! A new array is created on each render.
  // <MyContext.Provider value={[count, setCount]}>

  // Instead, useMemo to memoize the value
  const value = React.useMemo(() => [count, setCount], [count]);
  return (
    <MyContext.Provider value={value}>
      <ChildComponent />
    </MyContext.Provider>
  );
}

Context + Hooks = Redux-like State Management

With the introduction of hooks in React 16.8, we can actually get pretty close to Redux-style state management using just the Context API and a couple hooks.

The idea is to create a custom Provider component that manages the state with useReducer. We pass the state and dispatch function in the context value. Consuming components can then access state and dispatch actions using the useContext hook.

Here‘s a basic example of a custom "store" using Context and hooks:

const StoreContext = React.createContext();

function storeReducer(state, action) {
  switch (action.type) {
    case ‘increment‘:
      return { ...state, count: state.count + 1 };
    case ‘decrement‘:
      return { ...state, count: state.count - 1 };
    default:
      throw new Error(`Unhandled action type: ${action.type}`);
  }
}

function StoreProvider({ children, initialState }) {
  const [state, dispatch] = React.useReducer(storeReducer, initialState);
  const value = React.useMemo(() => [state, dispatch], [state]);
  return (
    <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
  );
}

function useStore() {
  const [state, dispatch] = React.useContext(StoreContext);
  return { state, dispatch };
}

We can use this custom store in our components like so:

function App() {
  return (
    <StoreProvider initialState={{ count: 0 }}>
      <Counter />
    </StoreProvider>
  );
}

function Counter() {
  const { state, dispatch } = useStore();
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: ‘decrement‘ })}>-</button>
      <button onClick={() => dispatch({ type: ‘increment‘ })}>+</button>
    </div>
  );
}

This setup gives us a centralized state container with a reducer to manage updates, similar to Redux. The useStore hook provides a convenient way to access state and dispatch actions from any component.

While this pattern doesn‘t give us all the bells and whistles of Redux, it can be a good middle ground for medium-sized apps that need more structured state management than plain Context Providers but don‘t require all the overhead of Redux.

Conclusion

The new Context API is a powerful tool in the React state management toolbox. For apps that have outgrown local state but don‘t necessarily require the full power of Redux, Context can provide a simpler way to share state across the component tree.

Migrating from Redux to Context can lead to a more streamlined architecture with less boilerplate. However, it‘s important to consider the tradeoffs and potential performance issues of the Context API pattern.

Using Context alongside hooks like useReducer can approximate a Redux-like centralized store while still avoiding much of the complexity. As with all architectural decisions, the right choice depends on the specific needs and scale of your application.

Redux still absolutely has its place in the React ecosystem, particularly for large, complex applications that benefit from robust tooling, middleware, and strict enforcement of unidirectional data flow. But for many apps, Context is a simpler and lighter-weight solution.

As React continues to evolve, it‘s exciting to see how built-in state management solutions like Context are changing the landscape and making it easier than ever to build performant and maintainable applications. By understanding the tools available and their tradeoffs, we can make informed decisions about the right state architecture for our projects.

Learn More

Similar Posts