T
TanStackโ€ข3y ago
unwilling-turquoise

Persistence: how to call save only when `data` is updated?

I'm using persistQueryClient function to restore, subscribe and save query cache to async storage. What I noticed in the sources is that persistQueryClientSubscribe subscribes to all changes in query cache and calls persistQueryClientSave in callback:
queryClient
.getQueryCache()
.subscribe(() => {
persistQueryClientSave(props)
})
queryClient
.getQueryCache()
.subscribe(() => {
persistQueryClientSave(props)
})
It means that persistQueryClientSave is called every time when e.g. query status change or new useQuery hook is mounted on new screen. Basically in my app persistQueryClientSave is called a lot and even with throttling enabled it still feels that sometimes it is called unnecessarily. Please correct me if I'm wrong with this example: I have query with query key ["users"] and I have useQuery hooks for this key mounted both in screen A and screen B. They both have staleTime set to 1 hour. Screen A is mounted and it fetches data successfully. Then I go to screen B where data is not refetched because it's still fresh. So the data is still exactly the same but persistQueryClientSave is called because subscriber is triggered by observerAdded or observerOptionsUpdated events. So my question is - is it possible to call persistQueryClientSave only if data in any query is updated? Maybe there is a way to subscribe only for data changes? Or maybe I'm missing something and it works like that for a reason? I was also thinking about calling persistQueryClientSave in onSuccess callback in default options. Although I don't really like this solution because it will break if onSuccess is overwritten and it also is called multiple times if more useQuery hooks are mounted for single query. Thanks for any help and suggestions here ๐Ÿ™
21 Replies
rival-black
rival-blackโ€ข3y ago
Hi ๐Ÿ‘‹ The entire query client is persisted so that it can be hydrated at a later date, not just query data. Have you noticed the throttled persistence to be problematic? If not, I'd be tempted to let it work its magic without doing anything imperative yourself. If it is, let me know and I'll have a look into it.
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
Hey, thanks for the response! So I'm developing react-native mobile app and my react-query cache is relatively heavy. Every save operation needs to first stringify all the data and then save it so it is pretty expensive. Every time it is executed I see some FPS drops on JS thread. Mostly on android devices and some older iPhones. It is especially visible during screen transitions, when e.g. new screen is pushed to or removed from the stack. If this screen use useQuery, it always triggers persistQueryClientSubscribe by dispatching events like observerAdded or observerOptionsUpdated on mount/unmount, which result in save operation being executed during screens transition. It results in visible lags during screen animations, especially on older devices. I tried to increase throttling, but the thing is that when you call throttled function for the first time it is executed immediately and only delay further calls happening in short time after. So save operation is always done in the exactly same moment when screen transitions start. Possible solution for that would be to modify throttling function to not be called immediately after first call, but only after throttle period.
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
Also I see that by default tanstack-query persisters save only query with 'success' status. Attached image shows the example shape of data saved to the storage. I see that besides just data it contains also all the timers, updates count, errors info etc. But I'm wondering if anything bad could happen if we trigger save only when the actual data of any query is changed
No description
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
Another idea would to call save only for specific event types in subscriber and ignore all observer-kind events?
const unusbscribeMutationCache = props.queryClient
.getMutationCache()
.subscribe((event) => {
if (its not observe kind event) {
persistQueryClientSave(props)
}
})
const unusbscribeMutationCache = props.queryClient
.getMutationCache()
.subscribe((event) => {
if (its not observe kind event) {
persistQueryClientSave(props)
}
})
But then I guess problem with screens mounting will be the same due to 'query added' events
rival-black
rival-blackโ€ข3y ago
I tried to increase throttling, but the thing is that when you call throttled function for the first time it is executed immediately and only delay further calls happening in short time after.
I understand what you're saying but that's how throttling works. I think you're essentially describing a "delay persistence by N milliseconds after change" mechanism. I think you could implement this yourself by writing your own storage persister and deferring the actual persistence by a given amount of time but I'm not sure if this would have other implications.
I see that besides just data it contains also all the timers, updates count, errors info etc. But I'm wondering if anything bad could happen if we trigger save only when the actual data of any query is changed
I think that'd lead to situations in which you're not persisting the state of the query client accurately. I don't think I have enough domain knowledge of the library here to reason about this so I'll leave this for someone else who has ๐Ÿ™‚
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
Yep, I was thinking about custom persister like you suggested. But I'm still wondering if solution I mention could lead to some unexpected behaviors ๐Ÿ˜„ Thanks for your help Louis! ๐Ÿ™
rival-black
rival-blackโ€ข3y ago
No worries, I'd be interested to know what's suggested here :reactquery:
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
So I'm digging into this more and what I noticed is that observerOptionsUpdated is emitted in my app a lot. Also when nothing changes on emitted observer options. It looks like it's emitted every time component using useQuery is re-rendered. Here is very minimal sandbox reproducing that (see console for logs). Nothing changes about query options but event is emitted on re-render anyway. https://codesandbox.io/s/pedantic-moser-gmzwkt?file=/src/index.jsx In my app (which is music player) the case was that in the same hook I was using some useQuery and hook for checking progress of the audio player, which is updated every second. So it in the end it resulted in saving whole query cache to storage every single second when the audio is playing. Now when I know that, I can actually prevent that this specific scenario, but I can imagine it's pretty often to use useQuery hook and unrelated useState in single component. In that case every state changes in this single component will tigger persistQueryClient save. @TkDodo ๐Ÿ”ฎ sorry for mentioning you directly but I'm super curious about your opinion on that ๐Ÿ™
emzet93
CodeSandbox
pedantic-moser-gmzwkt - CodeSandbox
pedantic-moser-gmzwkt by emzet93 using @tanstack/react-query, @tanstack/react-query-devtools, axios, react, react-dom
yelping-magenta
yelping-magentaโ€ข3y ago
observerOptionsUpdated is emitted in my app a lot
yeah, we only shallow equal compare the options, but inline functions etc. will nullify this check and emit basically on every render: https://github.com/TanStack/query/blob/6eac22faabee924dab9bb7f41b8bf7b1a92d3fca/packages/query-core/src/queryObserver.ts#L155-L161 it's a relatively new event that we added for the devtools I believe, I think @MrMentor made this to fix something in the devtools...
GitHub
query/queryObserver.ts at 6eac22faabee924dab9bb7f41b8bf7b1a92d3fca ...
๐Ÿค– Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query. - query/queryObserver.ts at 6eac22faabee...
yelping-magenta
yelping-magentaโ€ข3y ago
I'm not sure if we shouldn't just remove this event again and find another way to have the devtools stay up-to-date with that information ...
yelping-magenta
yelping-magentaโ€ข3y ago
also, we are currently ignoring the QueryCacheNotifyEvent. what we could at least do is filter out events that have no impact on the actual persistence, like everything that has to do with observers: https://github.com/TanStack/query/blob/6eac22faabee924dab9bb7f41b8bf7b1a92d3fca/packages/query-core/src/queryCache.ts#L61-L64
GitHub
query/queryCache.ts at 6eac22faabee924dab9bb7f41b8bf7b1a92d3fca ยท T...
๐Ÿค– Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query. - query/queryCache.ts at 6eac22faabee924...
yelping-magenta
yelping-magentaโ€ข3y ago
events pertaining to an observer shouldn't (cannot) have any influence on the cache itself, so I think they could be ignored here
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
So that's exactly what I planned and what I wanted to confirm here. Thanks you so much for such detailed explanation @TkDodo ๐Ÿ”ฎ! Here is what I ended with:
await persistQueryClientRestore({
queryClient,
persister,
maxAge: CACHE_TIME,
});

queryClient.getQueryCache().subscribe(event => {
if (['added', 'removed', 'updated'].includes(event.type)) {
persistQueryClientSave({queryClient, persister});
}
});
await persistQueryClientRestore({
queryClient,
persister,
maxAge: CACHE_TIME,
});

queryClient.getQueryCache().subscribe(event => {
if (['added', 'removed', 'updated'].includes(event.type)) {
persistQueryClientSave({queryClient, persister});
}
});
I love how composable and modularized is that library. The fact that instead of using complex persistQueryClient I can use all it's parts separately without any hacky solutions with patch-package is just awesome ๐Ÿš€
yelping-magenta
yelping-magentaโ€ข3y ago
GitHub
query/persist.ts at 6eac22faabee924dab9bb7f41b8bf7b1a92d3fca ยท TanS...
๐Ÿค– Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query. - query/persist.ts at 6eac22faabee924dab...
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
I would love to!
yelping-magenta
yelping-magentaโ€ข3y ago
the fix is straight forward - testing might be a bit harder ๐Ÿ˜… i can assist you once the PR is up if you want ๐Ÿš€
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
awesome, I'll try to setup a PR later today
wise-white
wise-whiteโ€ข3y ago
I believe we did this change to notify about the state of observers and their options which were not updating in devtools. https://github.com/TanStack/query/pull/3989 Since devtools get only queryClient instance they can only subscribe to cache updates. We could potentially narrow down those events to be fired only when options change that are globally relevant?
yelping-magenta
yelping-magentaโ€ข3y ago
I think the shallowEqualObject check is unnecessary because options will contain functions, like the queryFn, which is often inlined. So we could at least compare that. Then, we could think about narrowing it down to events that make sense. Maybe comparing enabled is enough right now b/c that was the thing we were interested in in the devtools in the first place. However, even if too many events are emitted, I think having the persister filter the events to which it is interested is a good fix anyhow! a good solution might be to split the events into two buckets: cache related and observer related. we could only pass the query for cache related ones, and the observer for observer related ones. The observer has .getCurrentQuery() anyways users need access to it. Then, the filtering becomes as easy as saying: if ('query' in event) because all query related events have the query, while observer related events have the observer. Of course, this would be for v5 cause it's technically breaking ๐Ÿ˜‰
unwilling-turquoise
unwilling-turquoiseOPโ€ข3y ago
here it is: https://github.com/TanStack/query/pull/4884 I see that vercel failed, not sure why
yelping-magenta
yelping-magentaโ€ข3y ago
we can ignore that ๐Ÿ™‚

Did you find this page helpful?