Migrating from MobX (State-Tree) to React Query
TL;DR: You should use React Query if:
- You fetch data from your backend frequently with minor or no changes (views or data transformations).
- You re-fetch data from your backend after mutable operations (post, put, delete, patch).
- You need polling or pagination.
Now, for the longer version.
Why change?
For the past six months, I have been working on a new project. Having already confirmed the usefulness of a TypeScript based stack, we decided to go for it again:
- Backend. Nest.js, TypeORM, PostgreSQL.
- Frontend. React with MobX State Tree for state management.
So far so good. I felt especially at ease since I already developed within the MobX State Tree framework for over a year and, despite my initial reluctance to shift from a more classical Redux environment, I ultimately found the MobX paradigm to appease my personal taste a lot more than its competitor. Nevertheless, as time passed, I couldn’t help but to notice several code smells; particularly in dealing with fetching and pushing data from and to the backend. Let me explain.
The MobX State Tree approach
MobX describes itself as follows:
MobX is a battle tested library that makes state management simple and scalable by transparently applying functional reactive programming (TFRP).
It provides a set of utilities through which object changes can be observed and therefore reacted to. In other words, it allows a state to be observed and managed. However, it does not actually provide a facility for manging a client-side application state. That’s where MobX State Tree comes into play.
Technically speaking, MobX-State-Tree (also known as MST) is a state container system built on MobX, a functional reactive state library.
Put bluntly, MST equips developers with a big JavaScript object used as a container for the application state, which can be reached from any point of the application, no matter how deep. Components can then affect siblings, descendants or anscenstors without the need for endless props forwarding. On top of that, it also enforces zealous type checks, so that whenever incompatible data is placed into the state against its previously defined model (e.g. a number into a string field, or a null into a non-nullable field), an error is thrown.
MST is a nice library. Models define what can be stored in the state, views derive computed data from the existing state and provide explicit memoization points, while actions can be used to immutably affect the state through familiar mutable-like operations. We used the model/view/action paradigm to describe all our client-side state, including the structure of our asynchronous operations.
Describing asynchronous operations
All products I have worked on in the past three and a half years are client-side rendered single page applications. As any developer knows with this kind of application, interaction with the backend happens asynchronously. Before a request is served by the backend, time will pass. Additionally, not all requests will be successful. When fetching a resource, then, we need to represent several different scenarios.
- Empty. When the resource is not present and the frontend didn’t yet issue a request for that resource.
- Loading. When the resource is not present, but the frontend already issued a request for it and it is waiting for a response. We usually like to display some kind of indicator during this phase, like a spinner, so that users know that the application is working.
- Error. When the resource is not present and the request has received a response, but the response only contained an error (code or message, or any other indication of an unexpected operation). In this case, we cannot display what we originally intended and must warn the user that something went wrong, possibly giving them an explanation and a way to amend the incorrect state.
- Success. When the request has received a successful response, which contains the intended resource. Now rendering may continue on its golden path.
I am using the term “resource” to indicate a piece of data that is stored in the backend but is needed in the frontend. The latter will need to issue a request in order to obtain the most recent version of the resource for display.
Now, this sounds easy enough. After all, MST allows for an observable state. Changing the state based on the outcome of a promise (our asynchronous vector) will cause the UI to re-render and apply the most appropriate visualization. There’s nothing inherently wrong with that. In fact, I even wrote a handy factory function that we used across the whole codebase.
1 | /* eslint-disable no-param-reassign,@typescript-eslint/explicit-module-boundary-types */ |
We’d use the code above to quickly create MST state fragments for remote resources. Then, we’d have a React component shaped as follows:
1 | export const SomeEntityView = observer((): React.ReactElement => { |
Async
is a helper component to take care of the loading, success and error scenarios. As you can see, the asynchronous request is initiated as a side effect through useEffect
and MobX observes stateFragment
to notify the component of any update in its fields.
The streamlined framework we built to manage asynchronous operation works and thanks to the factory method we have very low duplication. However, it got more complicated when we introduced polling as a capability. As you can see, we are managing low level details like request ids in order to disambiguate between overlapping requests that are received out of order. Additionally, creating views requires going through the map
helper, in order to avoid a plethora of undefined checks.
The need, albeit diluted over time, for handling low level concerns evoked a feeling of skepticism in me. “This cannot be the only way”, I thought. Enter React Query.
React Query
We didn’t inherently have a problem in our codebase… yet. However, the thought of having to implement pagination and infinite loading for virtualized lists on top of MST spooked me. Too many details to mind, which didn’t really bring value to the application. Not directly, at least, and not fast enough. Hence the choice to delegate all such details to a library that is built for that precise purpose, a library that is concerned with server-state first. We used a client-state library to represent server-state, hence the slight but noticeable disconnect between MST capabilities and our needs.
React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your React applications a breeze.
I only recently learned about the existance of React Query. Very often, not knowing what is out there is also the cause of sub-optimal tech choices. Thankfully, many people at Mind Foundry like to share articles, documentation and news about the tech panorama, so that we can stay updated on a constantly evolving state of the art.
I can identify one specific event that made it click for me. While developing a feature in the project I am currently working on, I incurred the need to call several endpoints from different components. The components themselves are dynamically generated at runtime, client-side, by interpreting a declarative piece of JSON. Each component specification may also contain the name of a well-defined endpoint (an “action”) that needs to be called when the component is mounted, so as to populate itself with correct data for visualization. With this scenario in mind, I had to write a piece of code that imperatively collected all actions from the JSON specification, removed all duplicates, and made the result available to all components. This is exactly what React Query does. I re-implemented the query cache from React Query on top of MST. After reading briefly about React Query, I realized at this very moment that I was breaking one of the dogmas of engineering: don’t reinvent the wheel! Riding the tide of such a crashing realization and with an eye to the future, I decided to spend some of my personal development time migrating a small chunk of code to React Query. After a first exploratory phase and after conferring with the team about it, we decided to proceed with the shift. Our codebase is still small and manageable enough that it only took a couple of days.
What’s so special about React Query anyway?
React Query applies a paradigm shift to data fetching, making it declarative instead of imperative. To me, using such a library makes a lot of sense, considering that React is a declarative framework. It feels right, because it embraces the same philosophy as the technology it’s catering to. The need for invoking side effects at render-time is now replaced with a more comfortable custom hook, which can be called from any component, anywhere in the application stack, without the need for observables. Read the following code:
1 | const queryKeys = { |
It defines a custom hook whose main job is to fetch a bunch of tasks to populate the user’s inbox. Quite easy and uncomplicated.
Its first parameter, queryKeys.inbox
, defines a unique string array that identifies this query among all other queries, so that, regardless of how many times the hook is invoked within a page, its associated fetching operation is only performed once. The library is smart enough to remove duplication through a well-thought-out caching system, which supports time-to-live and invalidation, like any other cache. It takes care of problems I was going to address myself by design. One less thing to worry about, yay!
Its second parameter implements the actual fetching (or posting, patching, putting, deleting). You can plug in any asynchronous request library you prefer and it unlocks a flexibility in handling the actual network operations, which may not even involve the network at all: any promise-based routine can be handled this way!
Then, the final parameter, options, unlocks a wide range of capabilities. enabled
is a boolean flag which turns the query on or off and can be used for an array of use cases. In the example above, we don’t want the inbox to refersh when a modal is visible. This option allows us to achieve that. Perhaps more useful in other circumstances, enabled
allows queries to depend on each other, so that a query doesn’t fire until data required to issue it is available!
refetchInterval
causes a re-fetch to happen on a timer. That’s polling. One line of code: much easier than implementing a polling mechanism over MST.
Finally, select
applies transformations to the received data. It can be used to re-enact some of the functionality of MST views, or you can rely on the usual useMemo
hook. This is probably the weaker side of React Query, but we need to consider that the library only manages server-side state, hence the lack of flexibility here.
The result of the custom hook above - UseQueryResult<InboxResponse, Error>
- is an object containing a plethora of useful properties, among which I have to recount the most useful ones: isLoading
, isSuccess
, isError
, isIdle
, error
and data
. The latter subset of properties contributes in completely specifying every scenario I listed in my introduction, so that we may render appropriate UI fragments depending on the state of the query: whether it is loading, it has failed because of an error, or it finished successfully.
Pushing data upstream: mutations
Fetching only highlights half of the problem. The other half includes mutating operations like creating, updating or deleting resources. React Query provides a way to close the loop with mutations:
1 | export const useInboxTaskAssignmentMutation = (): UseMutationResult<SuccessResponse, Error, InboxTask> => { |
The custom hook shown above represents a POST request through which an user can assign a task to themselves from the inbox. The interface contract of useMutation
is very similar to useQuery
, yet its semantic is different: mutations are never retried by default, because they are not idempotent, in the general case. Mutations are therefore still imperative and need to be explicitly called as a reaction to user input. Apart from that, the result of the custom hook above is quite similar to the one from useQuery
: it still contains fields like isLoading
, isSuccess
, etc… Moreover, it also contains a function called mutate
that can be used to initiate the mutation.
As the documentation explains, mutations can be very useful to invalidate existing queries, effectively evicting their associated query entry from the query cache and forcing a refresh. In the example above, I am using another custom hook that fires off an invalidation without waiting for all the affected queries to be re-fetched.
1 | export const useFireAndForgetInvalidation = (...keys: Array<QueryKey>) => { |
One of the strongest selling points of query invalidation consists in its hierarchical nature. If we define queries with the following keys:
- tasks
- tasks, 8
- tasks, 5, assignee
Then invalidating the key ["tasks"]
will also perform a pattern matching search on all existing query keys and invalidate any whose prefix matches, effectively causing a re-fetch for all of the above.
In conclusion
To recap, React Query seemed a good choice for our project. It handles a lot of the concerns we used to code ourselves, lifting the burden of maintenance from our shoulders so that we can focus on domain logic. Beware, we didn’t get rid of MST, though. There are still some fragments of the application state that are purely client side and they need to be handled separately. I still think MST is a great tool and we’ll keep on using it - just not to keep server-state in sync! As with everything in engineering, no solution fits all problems.
Ultimately, this post focuses on the key points that motivated our shift and how React Query removes complexity from our side. It is not meant to sell it as a technology in a vacuum. Surely six months from now, I’ll be fighting with some unintuitive defaults or some overly smart behaviour that conflicts with our business logic. Yet, for now, all is well at Mind Foundry.