-
The current persistor API and experimental implementations for both web and React Native will work for minor use cases, however the general approach isn't easy to make performant as it assumes the entire state should be persisted on every change and is always available after initial restoration. The current API looks like this: export interface Persistor {
persistClient(persistClient: PersistedClient): Promisable<void>;
restoreClient(): Promisable<PersistedClient | undefined>;
removeClient(): Promisable<void>;
} Even when throttling the The issue isn't unique to React Query though. Redux Persist tries to mitigate this issue by allowing setting a custom persistor for each reducer. This approach is also not sustainable when 1 payload can be 1 MB, and you potentially need to store several. On web (let's face it: Desktop machines) apps typically are short lived, counted in seconds and perhaps a minute or two. In React Native, apps can live on for hours, and with a persistor, days. In these situations it's favorable that we have a better API for persisting according to the cache key of the data. E.g. (pseudo-code) instead of caching as such: await AsyncStorage.setItem('REACT_QUERY_OFFLINE_CACHE', JSON.stringify(client)); You'd store each piece of data as such: for(const query of clientState.queries) {
await AsyncStorage.setItem('RC_${query.queryHash}', JSON.stringify(query.data));
} This can be implemented using the current API, but it would cause re-writing of the entire cache, for every query data entry. It makes the app more responsive because every item is awaited but overall performance is roughly the same (or slightly worse because of await having a slight overhead). This general challenge is not unique to React Query. Redux Persist has it too. They allow breaking reducers into separate storage configurations such that you can store data separately and avoid having to re-serialize everything even for small changes. However, this approach also doesn't address storage of individual items - you have to persist the entire reducer so if your data is stored as such:
Then you need to re-serialize myReducer in it's entirety even if only I understand that the current API is experimental. I think now is a fantastic time to consider this use case for mobile apps where memory is severely constrained but disk usage isn't. |
Beta Was this translation helpful? Give feedback.
Replies: 9 comments 27 replies
-
@scarlac I was also thinking along these lines and noticed #2864 was merged to potentially allow for custom functions for serialisation/deserialisation. Would that help? |
Beta Was this translation helpful? Give feedback.
-
I'm having the same issue as well. I cannot use react-query's persistor on a native app because it will use too much memory and the OS will terminate the app. Since most native apps need to be offline-first, this makes react-query unusable in my case. It's unfortunate as I really like the library but for now I cannot use it and directly use axios with realm.js. |
Beta Was this translation helpful? Give feedback.
-
@scarlac I'm open for suggestions on how to improve this for v4. Just so that I understand what we'd want here:
I guess this wouldn't alleviate the memory pain much, as we would still have the cache in memory. I guess unused queries could be cleared out by setting Maybe we would need to find a way to keep queries persisted while having them cleared from memory. I'm open to suggestions here :) @gabrielbull react-query only keeps one copy per query of your data in memory, so I'm not sure how a few MB of data can lead to hundreds of MB memory usage 🤔 |
Beta Was this translation helpful? Give feedback.
-
To try and narrow towards a proposal: Memory managementTo actually reduce memory, RQ would probably need to introduce additional timers in
|
Beta Was this translation helpful? Give feedback.
-
Instead of rehydrating queries with the persister, would it be possible to create a query cache with a persistent synchronous storage client? What if there was a query cache that replaced it's references to queries and queriesMap with something that:
Looking at the query cache, there are a few methods that expect the queries to be hydrated:
I'm not certain how having to rehydrate queries would effect onFocus and onOnline, or if there is a way to only have to call these methods on only active queries? |
Beta Was this translation helpful? Give feedback.
-
@TkDodo Any major changes in v4 that could potentially carve a path for a lazy loaded query cache, and/or persisting/restoring data per-query? |
Beta Was this translation helpful? Give feedback.
-
We've created an issue to track this for v5 here: |
Beta Was this translation helpful? Give feedback.
-
We've shipped this in v5.0.0-beta.28: https://tanstack.com/query/v5/docs/react/plugins/createPersister PLEASE try it out and give feedback, because there's still time to change things now :) |
Beta Was this translation helpful? Give feedback.
-
I have the same issue. The more cache I want to store the more app performance drops. Which is working pretty flawlessly: export const queriesStorage = new MMKV({ id: "queries" });
const queriesTimestamps = new Map<string, number>();
const xj = new XtraJson();
export const queryClientPersister: Persister = {
persistClient: throttle((client) => {
const time = Date.now();
let updated = 0;
let deleted = 0;
queriesStorage.set("BUSTER", client.buster);
queriesStorage.set("TIMESTAMP", client.timestamp);
const keysToLive = new Set(["BUSTER", "TIMESTAMP"]);
for (const query of client.clientState.queries) {
if (query.state.status === "success" && !query.state.isInvalidated) {
keysToLive.add(`QUERY_${query.queryHash}`);
const updatedAt = queriesTimestamps.get(query.queryHash) ?? 0;
if (updatedAt < query.state.dataUpdatedAt) {
updated++;
queriesTimestamps.set(
query.queryHash,
query.state.dataUpdatedAt,
);
const data = xj.stringify({
queryKey: query.queryKey,
state: query.state,
});
queriesStorage.set(`QUERY_${query.queryHash}`, data);
}
}
}
for (const key of queriesStorage.getAllKeys()) {
if (!keysToLive.has(key)) {
deleted++;
queriesStorage.delete(key);
}
}
console.log(
`[queries] Saved in ${Date.now() - time}ms. Items count: ${keysToLive.size}. Updated: ${updated}. Deleted ${deleted}`,
);
}),
restoreClient() {
const time = Date.now();
const result = {
buster: queriesStorage.getString("BUSTER") ?? "",
timestamp: queriesStorage.getNumber("TIMESTAMP") ?? 0,
clientState: {
queries: queriesStorage
.getAllKeys()
.filter((key) => key.startsWith("QUERY_"))
.map((key) => {
const queryHash = key.replace("QUERY_", "");
const { queryKey, state } = xj.parse(
queriesStorage.getString(key) ?? "{}",
) as {
queryKey: string[];
state: QueryState;
};
queriesTimestamps.set(queryHash, state.dataUpdatedAt);
return {
queryHash,
queryKey,
state,
};
}),
mutations: [],
},
};
console.log(
`[queries] Restored in ${Date.now() - time}ms. Items count: ${result.clientState.queries.length}`,
);
return result;
},
removeClient() {
for (const key of queriesStorage.getAllKeys()) {
queriesStorage.delete(key);
}
queriesTimestamps.clear();
console.log("[queries] purged");
},
};
function throttle<TArgs extends Array<unknown>>(
func: (...args: TArgs) => void,
wait = 100,
) {
let timer: ReturnType<typeof setTimeout> | null = null;
let params: TArgs;
return function (...args: TArgs) {
params = args;
if (timer === null) {
timer = setTimeout(() => {
func(...params);
timer = null;
}, wait);
}
};
} |
Beta Was this translation helpful? Give feedback.
We've shipped this in v5.0.0-beta.28:
https://tanstack.com/query/v5/docs/react/plugins/createPersister
PLEASE try it out and give feedback, because there's still time to change things now :)