Choosing between Redux and React’s Context API
Choosing between Redux and React’s Context API
While I worked on a recent project at work, I had to implement filtering and sorting (for a table) through some URL query parameters. I was surprised to find that react-router does not really support query parameter serialization out of the box (but that’s another story). I started working with my team to design a way to serialize the current filters into the URL, and quickly realized that UI components throughout the app’s component tree would also need access to these filters. For example, the dropdown (above each column in the table, that you use to select filters) needs to show the currently selected filters for that column.
I wanted to have a class with all the methods to convert the filter data to/from query params. This class could use React’s Context API to pass the data to components throughout the tree. We already had Redux inside the project as well, and I wasn’t sure whether Redux and Context should be used together. I wasn’t able to find any articles online about using the Context API in an app which is already connected to Redux. But mostly I was just curious as to whether this serialization class could provide these values to child components through the Context API instead. Why not add the state to the same class that is already handling the conversion to/from query params? That’s when I decided to dive into the newest (stable) Context API.
What is Context
Context provides a way to pass data through the component tree without having to pass props down manually at every level — React docs
Context is designed to share data that can be considered “global” for the entire React app. A common example is data about the current logged in user.
For example if we have something like this;
User information is needed in the Navbar, so we are passing down the user through the Header component. But the Header component only needs the user object to pass it to the Navbar. In times like these we can use Context to avoid props drilling.
Context lets you “broadcast” such data, and changes to it, to all components below.
Let’s see how we can use Context.
Using Context API
const { Provider, Consumer } = React.createContext()
createContext creates Provider and Consumer as a pair. Provider is a component that allows consumers to listen / subscribe to context changes. They take a value prop which will be passed to Consumers that are descendants of this Provider.
Provider
<Provider value={user}>
A React component that allows Consumers to subscribe to context changes. Accepts a value
prop to be passed to Consumers that are descendants of this Provider.
Consumer
<Consumer> {value => /* render something based on the context value */}</Consumer>
A React component that subscribes to context changes. Requires a function as a child. The function receives the current context value and returns a React node. All Consumers that are descendants of a Provider will re-render whenever the Provider’s value
prop changes.
Example:
This is a refactor of our previous App component with context. Notice how the userContext. Provider is supplied a value which will be used down the tree by the userContext.Consumer.
Let’s use the user in the Profile component, using value from userContext.Consumer:
One thing to note is Consumer expects a child that is a function. In our example, you will see userContextConsumer has a function which will get the value from the Provider, which we are then using for the ProfileImage component.
Where I used Context
Coming back to the project, I needed the serialization class (let’s call it WithQueryParams) to have a context Provider. The Provider’s value prop would be the filters (as a JS object) from inside the state of WithQueryParams.
Any child component of WithQueryParams can now use queryParamsContext.consumer to access the filters. But what about changing those filters?
Updating the filters in WithQueryParams state
I added a method updateQueryParams which will update the query params when any new filters or sort are selected. You can read more here about updating from a nested component.
Here is what my class WithQueryParams started looking like;
updateQueryParams takes the new filters / sort from child components, updates it in the state of WithQueryParams class, and does a history push to change the route with the update filters:
The stateReducer, called in updateQueryParams, takes the current state of filters and sort along with new filters and sort (passed through child components). Then, it updates the state accordingly (initially this state reducer was not difficult to manage, however over a few weeks that changed).
Finally, we’ll wrap the Dropdown component within queryParamsContext.Consumer so that we’re able to access the queryParams and the updateQueryParams:
When the filter or sort buttons are clicked, they will update the state of WithQueryParams class through the method updateQueryParams method. All the consumers subscribed to the queryParamsContext will get the updated changes.
Doesn’t this seem familiar?
This pattern of a Provider holding a value and being able to be passed down to child components using Consumer may already seem very familiar to many of you who used Redux before. While I was also curious to try out Context API, we actually could have put the query Params in Redux –and I like Redux a lot.
Growing pain with growing requirements
A couple of days later, my team noticed that the filters weren’t being applied when we navigated back and forth, in our app, using back / forward button in browser. I jumped in with a fix by adding a condition in getDerivedStateFromProps to check whether the filters changed, and to update the state if so. There was also a risk of running into infinite loop by continuously updating the query params, parsing them, and updating the state. WithQueryParams class slowly but surely was getting complicated.
Is it time to move to Redux?
I had a new feature to implement — a “clear” button in Dropdown component to remove all filters for the column. I started updating the state reducer in the WithQueryParams class to take in a flag and remove any filters. At that point, before I actually shipped that feature, I paused for some time to reflect on if I should just have React manage the state of the query params, then dispatch specific actions to either change filters or sort or clear all filters. The explicit actions, predictability, and ease of debugging by being able to toggle Redux dev tools to check the current state of the app was already something I was missing while working with Context. Also CodeClimate, a tool we use to get feedback about our code, was already yelling about the cognitive complexity of the state reducer in the WithQueryParams class being way more than what was recommended.
Managing location in Redux with Connected-react-router
Another team suggested using react-router-redux (now deprecated) to manage location changes. I started looking into that and came across connected-react-router to do just that. I liked the uni-directional flow of data in connected-react-router. As browser history changes, it can dispatch a LOCATION_CHANGE action. We can listen to that action in a reducer, serialize it to a JS object, and set it in the store.
history -> store -> views
I still had the WithQueryParams class since it contains helpers for serialization.
Uni-directional data and the Redux store being the source of truth for location information meant a lot of trust in the application. Whenever the filters or sort changed in our Dropdown component, we would dispatch an action to push a history change, but not set the new filters directly in the store. Browser history change causes the LOCATION_CHANGE action to be dispatched by connected-react-router and, as mentioned above, the location would then be updated in the store.
Here is how the entry page, or the Main component started looking like, with connected-react-router. Notice that the ConnectedRouter receives history (I used history package) as a prop.
Here is the actions file, with an action to update filters (I added more later to update sort and clear all filters). QueryParams helper is the same as the WithQueryParams class that we previously had, except without any state. It has helper methods to help with serialization. The push method allows us to update the history with the updated query params. After that, connected-react-router dispatches the LOCATION_CHANGE action.:
We subscribe to LOCATION_CHANGE in one of our reducers to get the updated location, which we then validate and clean before updating it in the store:
Conclusion
Now that I’ve tried Context, I see that in our case, things were already too complex to let Context manage the query params and help with serialization. I can, however, think of a few use cases. If you have a small app or reusable components that you open source, Context would be very useful. Here is a Stack Overflow question about Context vs Redux that may shed some extra light on this.
If you have tried Context before, share your thoughts below in the comments. If you liked this post don’t forget to hit claps!