Scaling React State Management Part 2

Or why you might not need Redux

Andre Rabold
6 min readMay 25, 2020

In my previous post Scaling React State Management Part 1, I covered my approach on how to manage application states in React without the need of 3rd party libraries such as Redux.

A problem we haven’t solved so far is a common problem when using centralized states in large React applications: Performance.

The Problem

If you used the React Context API before you might already have encountered this: Your application state grows and while things seem to work well at some point the app becomes more and more sluggish, UI updates are hogging your CPU and even basic input forms become slow to respond. When you apply tools such as Why-did-you-render? you’ll notice a lot of UI re-renders of components that didn’t have any change in data. What is happening?

React is very smart about re-rendering only whats necessary and keeping updates to a minimum, but your application state is a single object and every time you perform a change the whole JavaScript object is thrown out and a new one created at its place. This triggers a re-render in every component that uses the application context — so, pretty much your whole application.

Every. Single. Time.

Usually, this is when people drop React’s Context API and go back to Redux.

Let’s look at a practical example. The following AppState is something that could be found in an Online Banking application for example. I didn’t include any fetching or error states to not overcomplicate things:

AppState.ts

Now imagine we’re building the applications home screen. We would have a couple of different components that can be combined into something vaguely resembling a banking app:

HomeScreen.tsx

Easy enough! If you’re wondering what AppContext would look like, please check out the first part of this series covering application context and action dispatching.

This is a quite simple implementation and something you would find in many different kinds of applications. It looks simple enough that most people don’t waste a second thinking about possible performance implications. Maybe you’ve heard of React.memo before and decide to wrap your UserDetails and Balance components with it, but that’s usually the extent of “performance optimization” people tend to think of. But when testing this you’ll eventually notice that our components render every single time anything changes in our application state, regardless of whether that component consumes that particular state property or not. The UserDetails will not only re-render when the user’s profile information changes, but also whenever the balance updates, or whenever a new transaction is added. Imagine we’re fetching the current balance once a minute — our application would re-render the whole screen with every component on it every minute as well!

The Solution

As already mentioned, every change to the state object will trigger a re-render of the components using it. When encountering the code above, React doesn’t look at which properties of the state you’re actually interested in, it just sees that you’re using useContext(AppContext) and that means it will render your component every time the AppContext changes. This problem gets bigger with every new component you create that uses useContext(AppContext). You basically have three options now:

  1. You can drop the use of useContext(AppContext) in your Balance and UserDetails components and inject the data using props instead. This way both can be memoized efficiently by React and will only re-render when there’s an actual change of any of these props. However, this solution defeats the whole purpose of the Context API as you end up prop-drilling again.
  2. You can split your application context into smaller objects, e.g. create a UserContext, a BalanceContext and a TransactionsContext. This is actually often a good idea as it keeps independent things nicely separated from each other. However, you’re just deferring the problem, not solving it. While your Balance and UserDetails components will render efficiently now, the TransactionsContext would potentially become very large and changes in one of its elements would still cause a re-render of all related components.
  3. You find a way of subscribing only to specific state changes that they are interested in. E.g. the Balance component is only interested in changes to the balance object of the application state. The UserDetails component only in the user object and so on. Changes outside of this subscribed sub-tree would then not cause a re-render of the component anymore.

This is where Redux really shines! Redux’s mapStateToProps and the useSelector hook is an elegant selector mechanism that allows you to pick and choose only the state variables you’re interested in and re-render only when one of these changes. This is one of the main reasons Redux’s out-of-box performance is superior to React’s Context API when dealing with centralized states like the one outlined above.

useContextSelector

The React team is of course aware of this shortcoming of the Context API and an RFC has been proposed though it is unclear how exactly it will be solved in an upcoming React update. I personally found the useContextSelector implementation by Daishi Kato the most intuitive solution for now, as it follows the original React Context API and Redux’s useSelector the closest.

Instead of fetching the whole state using const state = useContext(AppContext) you fetch only what you need by providing an additional selector function, e.g. const user = useContextSelector(AppContext, (state) => state.user). Only the change in values returned by the selector function will be considered when determining whether a component needs to be re-rendered or not. By returning only user the component will only re-render if something changes inside of the user part of the AppState. You can select as granular and as many properties as you need, even those from different sub-trees of the application state. Simple and intuitive.

So, how does this work in our application? Let’s take a look:

App.tsx

Most noticeably we are now using two contexts instead of one:

  1. AppStateContext is only providing the application state itself. Note that we’re using the createContext from the use-context-selector library here instead of React.createContext. It works the same way but this one will later allow us to subscribe only to the state updates we’re interested in.
  2. For the dispatch, we use a separate context so components that only need to dispatch an action don’t need to subscribe to state changes at all. The dispatch function will also not change so we don’t need to use a selector for it.

Now let’s take a look at how we can interact with both contexts. First let’s define two hooks that make interaction with the contexts easier and safer.

The useAppStateSelector hook provides the application state using a selector function as its sole argument. I have added TypeScript annotations to make the code safer to use:

useAppStateSelector.ts

The second hook is useDispatch which just returns the dispatch function itself:

useDispatch.ts

That’s it!

Using those two hooks our updated banking application components will look like this:

HomeScreen.tsx

I have also added a reloadBalanceAction action to our original example to better showcase the use of useDispatch.

Conclusion and a Look Forward

In Part 1 I showed how we can use React’s Context API and useReducer to build a flexible and powerful Flux architecture as an alternative to Redux.

In this second part we tackled performance issues caused by too many component updates when using centralized application states by introducing context selectors.

I hope I could show that React’s Context API is a serious alternative to 3rd party state management solutions. There are plenty and new ones seem to get released every day. One of the latest additions is created by Facebook themselves called Recoil. Why I have personally not used it yet, I’m certain it has its place amongst other behemoths such as Redux, MobX, RxJS and more lightweight alternatives like Zustand, etc. However, with libraries such as React-Query and Apollo the application state of modern React apps is becoming simpler and simpler. Many applications that are only built on top of APIs and don't have any local data will not even need their own state management anymore. If they do, it will need something simple and lightweight. That is exactly what React’s Context API is designed for and what you should be using it for. Just don’t use Redux or any other library simply because you always did, but choose your dependencies wisely and only use what you really need.

References

--

--

Andre Rabold

Venture CTO at UP.Labs, BCG Alumni, Co-Founder at Stashimi Inc., Entrepreneur, and Technologist