Normalize your React Query data with MobX State Tree
Mateo Hrastnik
Posted on November 16, 2020
Fetching data in React is deceptively hard. You start off with a simple useEffect
+ useState
combo and you think you're done.
"This is great!" you think to yourself...
But then you realize you didn't handle errors. So you add a bunch of code to handle that.
Then you realize you have to add a refresh button. So you add a bunch of code to handle that.
Then your backend developer tells you the data is paginated. So you add a bunch of code to handle that.
Then you want to trigger a refresh automatically every N seconds. So you add a bunch of code to handle that.
By this time, your data fetching code is an absolute nightmare and managing it becomes a headache, and we haven't even touched the subject of caching.
What I'm trying to say is that React Query is awesome. It handles all of the complexity listed above, and much more. So if you haven't yet, you should definitely give it a shot.
However, at Lloyds, we haven't always been using React Query. Not so long ago, we had a custom useQuery
hook that tried real hard to serve all our data fetching needs. It was good, but not nearly as good as React Query. However, as our useQuery was tightly coupled with MobX State Tree, we had a couple of benefits that we really liked:
- Typed models
- Data normalization at response time
- Data denormalization at access time
- Actions on models
Note - you can check out my article on how we used MST here: Why you should use MST
Typed models
With MobX State Tree, you're required to define the shape of your data. MST uses this scheme to validate your data at runtime. Additionally, as MST uses TypeScript, you get the benefit of having IntelliSense autocomplete all of the properties on your data models while you're writing code.
Data normalization and denormalization
What do I mean by this? Well, to put it simply - this ensures that there's only one copy of any given data resource in our app. For example, if we update our profile data this ensures that the update will be visible across the app - no stale data.
Actions on models
This is a great MST feature. It enables us to attach actions on the data models in our app. For example, we can write something like
onPress={() => {
article.createComment("I love this!");
}}
instead of the much less readable alternative
onPress={() => {
createCommentForArticle(article.id, "This doesn't feel good");
}}
or the even more complicated version
onPress={() => {
dispatch(createCommentForArticle(getArticleIdSelector(article), "I'm sorry Mark, I had to"));
}}
Moving to React Query meant getting the new and improved useQuery
hook, but losing the great MST features we just couldn't do without. There was only one option...
Combining React Query and MST
Turns out it's possible to get the best of both worlds, and the code isn't even that complicated.
The key is to normalize the query response as soon as it gets back from the server and instead of the raw resource data, return the MST instance from the query function.
We'll use the MST stores to define the data fetching methods and the methods for converting raw network response data to MobX instances.
Here's an example... First, let's define two models. These will define the shape of the resources we will fetch.
const Author = model("Author", {
id: identifier,
name: string,
});
const Book = model("Book", {
id: identifier,
title: string,
author: safeReference(Author),
}).actions((self) => ({
makeFavorite() {
// ... other code
},
}));
Next we'll define the stores to hold collections of these resources.
const BookStore = model("BookStore", {
map: map(Book),
});
const AuthorStore = model("AuthorStore", {
map: map(Author),
});
Let's add a process
action that will normalize the data and return the MST instances. I added some logic to the action so that it can handle both arrays and single resources and additionally merge the new data with the old - this way we avoid potential bugs when different API endpoints return different resource shapes (eg. partial data when fetching a list of resources vs full data returned when fetching a single resource).
We'll also add an action that will perform the HTTP request and return the processed data. We will later pass this function to useInfiniteQuery
or useQuery
to execute the API call.
const BookStore = model("BookStore", {
map: map(Book),
})
.actions((self) => ({
process(data) {
const root: StoreInstance = getRoot(self);
const dataList = _.castArray(data);
const mapped = dataList.map((book) => {
if (isPrimitive(book)) return book;
book.author = getInstanceId(root.authorStore.process(book.author));
const existing = self.map.get(getInstanceId(book));
return existing
? _.mergeWith(existing, book, (_, next) => {
if (Array.isArray(next)) return next; // Treat arrays like atoms
})
: self.map.put(book);
});
return Array.isArray(data) ? mapped : mapped[0];
},
}))
.actions((self) => ({
readBookList: flow(function* (params) {
const env = getEnv(self);
const bookListRaw = yield env.http.get(`/books`, {
params,
});
return self.process(bookListRaw);
}),
}));
const AuthorStore = model("AuthorStore", {
map: map(Author),
}).actions((self) => ({
process(data) {
const dataList = _.castArray(data);
const mapped = dataList.map((author) => {
if (isPrimitive(author)) return author;
const existing = self.map.get(getInstanceId(author));
return existing
? _.mergeWith(existing, author, (_, next) => {
if (Array.isArray(next)) return next; // Treat arrays like atoms
})
: self.map.put(author);
});
return Array.isArray(data) ? mapped : mapped[0];
},
}));
const Store = model("Store", {
bookStore: BookStore,
authorStore: AuthorStore,
});
That's basically it, we can now use the readBookList
method in our components with useQuery
or useInfiniteQuery
... Almost.
If you try it at this point, you'll get an error. That's because React Query internally uses something called structural sharing to detect if the data has changed. However, this is not compatible with MobX State Tree so we need to disable it. We can configure this using a top-level query client provider.
import { QueryClient, QueryClientProvider } from "react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
structuralSharing: false,
// ... other options
},
},
});
function App() {
// ... other code
return (
<QueryClientProvider client={queryCache}>
{/* ... other providers ... */}
<Router />
</QueryClientProvider>
);
}
All that's left to do is to actually try running the query.
function BookListView() {
const store = useStore();
const query = useQuery("bookList", (_key, page = 1) =>
store.bookStore.readBookList({ page })
);
// Convert array of responses to a single array of books.
const bookList = _.flatMap(query.data, (response) => response.data);
return (
<div>
{bookList.map((book) => {
return (
<BookView
book={book}
onPress={book.makeFavorite} // We have access to methods on the Book model
/>
);
})}
</div>
);
}
We get the flexibility of React Query without sacrificing the benefits of MobX State Tree.
You can check out the complete example on Code Sandbox here:
In the example, the API calls are mocked. In production, this would be replaced with the real fetch calls. You can notice how, when you enable the "Show author list" checkbox, it updates the author on the "Book list" section. There is only one instance of author-2
in the app, and everything stays in sync. We don't have to refetch the whole list.
Summary
React Query and MobX State Tree are great tools. But together, they are unstoppable. React Query gives us the flexibility to fetch data from the server just the way we want it. MST + TypeScript provide the type safety + intuitive way of adding methods and computed properties on the data models. Together they provide a great developer experience and help you build awesome apps.
Thank you for reading this! If you've found this interesting, consider leaving a ❤️, 🦄 , and of course, share and comment on your thoughts!
Lloyds is available for partnerships and open for new projects. If you want to know more about us, check us out.
Posted on November 16, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.