State Management - A Journey in React
It has been a while since my previous blog post about using React Context for global state management, as opposed to other libraries like React Redux and MobX. Even though I stand by what was said then and how things were done, time has taught me that it was just a stepping stone to the next better way of doing state management in React (hence, the subtitle of this post). I now advocate for the use of Redux Toolkit for state management and Redux Toolkit Query for API requests.
I was working on a dynamic web application with heavy analytical data, many RESTful API requests, and lots of page interactivity, but ran into performance issues and found the code base hard to maintain. I cannot disclose the exact scenario, but the page was filled with data grids and charts that had to know about each other - if you clicked on a row in the grid, the chart had to respond and it had to be quick. There were also many filters controlling the data retrieved by the API. Context was used to share state in the entire app, and fetch was used to retrieve data with RESTful APIs.
The problems I ran into were manifold. There were too many re-renders, the global state became very large, it was hard to keep track of the "source of truth", and the page became sluggish and unresponsive. Furthermore, the amount of API requests started to get out of hand, and it was clear that manually maintaining the API layer in the app was not feasible. Some of these issues were with using the native fetch API paired with AbortController. Even though it was seen as best practice at the time, in large and complex applications such as what I was working on, I ran into issues where the side effects led to debugging nightmares in the app. To make matters even worse the "code architecture" of the application was not very well thought through (it started as a prototype and had to become production-ready VERY quickly).
To summarize the problems - the application didn't scale well due to:Â
a lack of streamlined global state management.
the lack of a solid API layer on the front-end.
the lack of a solid front-end code architecture.
I explored various solutions to these problems and I will break them down below.
Having used Redux and MobX in the past and having learnt about Redux Toolkit in passing, my first inclination was to explore Redux Toolkit. I was already familiar with the redux pattern and knew it was a well-adopted library in the React community.
The documentation of RTK was very thorough and it was a breeze to get up to speed. The biggest issue I had with Redux in the past was its verbosity - to add one feature to the state was very hard work and you had to update many different files to get it working. With RTK this was not the case at all. In short, learning to use it was a quick and adopting it in my application was easy. I could also do it piece by piece, so the app still worked as I was migrating features from Context to RTK.
I also observed performance improvements while doing the migration, so I knew it was the right decision. Refactoring the entire app took time (about 2-3) weeks, but it was well worth the effort.
For the API layer I had to choose between SWR or RTK Query. I was using SWR for a while in the app before RTK was introduced, and it seemed to work alright, but it did have some unexpected side-effects, especially with regards to the serialize feature of SWR. (Serialize has been deprecated since, and I am curious to explore SWR again in future projects to see how it compares to RTK Query now.)
After studying how RTK Query works and incorporating it to some of the key endpoints where I was experiencing performance issues in my app, the benefits were very clear. The app became much more responsive because of the built-in caching behavior of RTK Query, and the flow of the code became much easier to understand. Debugging also became much easier because I didn't have to debug my own fetch wrapper when issues occurred. Using it is also very intuitive, and I knew we could adopt it in the app.
My favorite feature about RTK Query is its mechanism of Automated Re-Fetching. You simply specify tags (called tagTypes), and with providesTags and invalidatesTags tell the API configuration what data should be invalidated (re-fetched) if the data is mutated. It works great, but there are some caveats (out of scope for this discussion). It did allow me to remove a lot of code that manually handled re-fetches after mutations occurred.
Aside from the performance issues due the state management and (lack of) API layer, I quickly realized the codebase wasn't following a solid code architecture. After doing research I found that the approaches Bulletproof React architecture worked really well, and I refactored the code to that approach. Sometimes it is tedious, but it is ALWAYS great when you find the functions you were looking for in an expected place, so it is totally worth the effort.
By incorporating the fixes above, the performance of the app has been great, it is very easy to maintain the app and introduce new features. React Context is good at sharing local state between a parent component and its children, but it does not scale well, and for that I would recommend using Redux Toolkit Query.
To note, I have used this approach on a number of large, data-heavy applications, some even had to be more responsive to user interactions, and these technologies have proven to be great choices.