Redux and React context are not mutually exclusive
You may have come across a number of articles by now proclaiming “Redux is made obsolete by React context.”
The reality is, react-redux uses and has always used the React context API in its various forms since it debuted. “Rolling your own Redux” is something that was always possible — it’s just not necessary to reinvent the wheel.
Redux has a rich ecosystem of middleware, opinionated abstractions, test and developer toolkits, and documentation. It’s thoroughly tested, used at scale, and performant when done right. Its only job is to maintain an immutable store and broadcast updates when the store changes — and this is a job it does well. The react-redux library is specifically designed to take advantage of little-known and even unstable React features to minimize unnecessary re-renders.
Meanwhile, there’s no officially supported way to prevent components that consume any slice of a React context value without re-rendering.
The core functionality of Redux can be pretty easily reimplemented — after all, the entire codebase is rather small. But there’s little point in implementing your own middleware API, redux-saga, redux-thunk, DevTools, useSelector, etc.
All this is to say, I don’t believe in Redux as a preferred alternative to React context. Using the context API yourself is something I really encourage and have written a number of articles about. In my opinion, there is plenty of room for both within the same application — even using them together.
Let’s use reselect as an example. Reselect is a library for memoizing results of normalizing your store state.
A selector is just a function which translates slices of state into a more digestible format for a component.
const selectUsersList = createSelector(
state => state.users.order.map(id => state.users.byId[id])
)const useUsersList = () => useSelector(selectUsersList)
This yields the same result as without using createSelector
, although the advantage is that the selector has a cache. If it is called with the same state object, it returns the same result reference, thus reducing the need for a component that uses useUsersList
to re-render when the state doesn’t change.
Since state changes often, typically selectors have “input selectors,” which further reduce the parts of state to the ones that are relevant. This way, the selector’s cache can be reused until one of the inputs changes.
const selectUsersList = createSelector(
[state => state.users.order, state => state.users.byId],
(order, byId) => order.map(id => byId[id])
)
This selector returns the cached result array until either the order
or the byId
objects change.
A common issue that arises with reselect is the need to pass a component’s props into the selector as well as the state. For instance, a component like <Todo id={todoId} />
may need to pass the id prop to a selector in order to get the right todo from state.
The official docs suggest creating a selector factory in this case. Since selectors have a cache size of one, they aren’t useful at caching when the same selector is called with different arguments because multiple components on the same page are rendered simultaneously with different props.
const createSelectTodo = () => createSelector(
[state => state.todos.byId, (state, id) => id],
(byId, id) => byId[id]
)const useSelectTodo = id => {
const selectTodo = useMemo(createSelectTodo) return useSelector(state => selectTodo(state, id))
}
This in effect creates a copy selector when the component is first instantiated with its own cache.
What happens, though, when the children of the component also want to select the same value? Each one in turn creates its own selector as well. This means you can potentially have many instances of selectors which are actually caching the same data.
If the Todo component has child components which also select the todo by its id, then it makes more sense to hoist that id into context and create one selector.
const todoContext = createContext()const TodoProvider = ({ children, id }) => {
const selectTodo = useMemo(createSelectTodo)
const todo = useSelector(
useCallback(state => selectTodo(state, id), [id])
) return (
<todoContext.Provider value={todo}>
{children}
</todoContext.Provider>
)
}const useTodo = () => useContext(todoContext)<TodoSelectorProvider id={todoId}>
<Todo />
</TodoSelectorProvider>
Now all the children of the Todo component can get the current Todo that they are responsible for rendering with useTodo()
. 👏
With this hybrid approach:
- We’ve avoided prop-drilling. We don’t need to pass the todo object to all the descendants of the Todo component, nor do we need to pass its id.
- Only one selector will be instantiated per context Provider. There could still be duplicated selectors if multiple Providers are given the same id, but that’s unlikely and can usually be resolved by hoisting our Provider further up the component hierarchy.
- Our components won’t re-render until relevant slices of state change.
There’s a lot of potential here to leverage Redux and context together. Your context provider value might offer a complete API, its own (preferably memoized) object of methods for selecting store data and dispatching action creators.
Consider the ability to test your components without mocking your entire Redux store in your component tests. Instead, you’ll render your component nested inside whichever context Providers that component depends on. Pass mock data in for the context values and spies for your callbacks. All your React components become cleanly separated from your Redux, coupled only to your context wrapper components.
Meanwhile, you can test your Redux selectors, action dispatchers, and context providers in a completely separate suite of tests dedicated to your Redux store.
Of course, you’ll gain more confidence still with proper integration tests or E2E testing — but it’s still no substitute for quick, thorough, and straightforward unit tests. They shouldn’t and don’t have to be a pain, if React and Redux are split apart and joined by context.
For the most part, the conclusion I’ve reached is that Redux is a great candidate for truly global state — specifically, entities that have a server representation and require a single, up-to-date source of truth. Context, on the other hand, fits use cases in which a component’s state is relative to its parent really well. They aren’t at odds with one another; instead, they can complement each other.