v5, createPersister and IndexedDB: absolutely amazing! #6213
-
I'm using the latest v5 version: AMAZING!!!!!!! Thanks! I tried the I'm using it like this, what do you think? import {
experimental_createPersister,
type AsyncStorage,
} from "@tanstack/query-persist-client-core";
import { get, set, del, createStore, type UseStore } from "idb-keyval";
function newIdbStorage(idbStore: UseStore): AsyncStorage {
return {
getItem: async (key) => await get(key, idbStore),
setItem: async (key, value) => await set(key, value, idbStore),
removeItem: async (key) => await del(key, idbStore),
};
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 30, // 30 seconds
persister: !browser
? undefined
: experimental_createPersister({
storage: newIdbStorage(createStore("db_name", "store_name")),
maxAge: 1000 * 60 * 60 * 12, // 12 hours
}),
},
},
}); |
Beta Was this translation helpful? Give feedback.
Replies: 8 comments 21 replies
-
looks lovely |
Beta Was this translation helpful? Give feedback.
-
Hey! Thank you so much for the snippet. Works pretty great! One suggestion: The advantage of IndexedDB is that you can safely put objects into the storage without having to rely on serializing/deserializing the content like e.g. with LocalStorage. I have noticed that right now it would by default still stringify the content and then save it to IndexedDB. My suggestion would be to additionally add this into your persister: experimental_createPersister({
storage: newIdbStorage(createStore("db_name", "store_name")),
maxAge: 1000 * 60 * 60 * 12, // 12 hours,
// Just pass the data
serialize: (persistedQuery) => persistedQuery,
deserialize: (cached) => cached,
}) The only problem is that TypeScript will complain that this serialize/deserialize function is not assignable, because it expects a string as a return type and cached type. |
Beta Was this translation helpful? Give feedback.
-
With the newest version (v5.10), you can now add a type to the persister, so now we can combine my serializer solution with your IndexedDB solution :) import {
experimental_createPersister,
type AsyncStorage,
type PersistedQuery
} from "@tanstack/query-persist-client-core";
import { get, set, del, createStore, type UseStore } from "idb-keyval";
function newIdbStorage(idbStore: UseStore): AsyncStorage<PersistedQuery> {
return {
getItem: async (key) => await get(key, idbStore),
setItem: async (key, value) => await set(key, value, idbStore),
removeItem: async (key) => await del(key, idbStore),
};
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 30, // 30 seconds
persister: !browser
? undefined
: experimental_createPersister<PersistedQuery>({
storage: newIdbStorage(createStore("db_name", "store_name")),
maxAge: 1000 * 60 * 60 * 12, // 12 hours,
serialize: (persistedQuery) => persistedQuery,
deserialize: (cached) => cached,
}),
},
},
}); Under AsyncStorage and createrPersister, just add |
Beta Was this translation helpful? Give feedback.
-
When I use persistQueryClient, my data immediately appears upon reloading. However, when I switch to experimental_createPersister, my data is not there when reload. Could this mean I've set up something incorrectly? |
Beta Was this translation helpful? Give feedback.
-
Beta Was this translation helpful? Give feedback.
-
@frederikhors @TkDodo @TheTimeWalker Thank you for the suggested solutions here! I have an additional question regarding this new import { createStore, del, get, set, UseStore, keys, getMany } from 'idb-keyval';
export async function restoreQueryClientCache() {
if (!idbStore) {
return;
}
const cachedKeys = await keys(idbStore);
const cachedValues = await getMany<PersistedQuery>(cachedKeys, idbStore);
for (const queryData of cachedValues) {
if (queryData?.buster !== UI_VERSION) {
continue;
}
if (!queryData?.state?.data) {
continue;
}
queryClient.setQueryData(queryData.queryKey, queryData.state.data, {
updatedAt: queryData.state.dataUpdatedAt,
});
}
}
restoreQueryClientCache().catch(console.error);
root.render(...); I am migrating from the solution which was suggested here. import { get, set, del } from 'idb-keyval'
import { PersistedClient, Persister } from '@tanstack/react-query-persist-client'
export function createIDBPersister(idbValidKey: IDBValidKey = 'reactQuery') {
return {
persistClient: async (client: PersistedClient) => { await set(idbValidKey, client) },
restoreClient: async () => { return await get<PersistedClient>(idbValidKey) },
removeClient: async () => { await del(idbValidKey) },
} as Persister
} With the previous solution, the query would initially resolve with the data from the cache (which was quite nice as if one reloads the page, they immediately see the previous data - no loading state). After migrating to the new Is a workaround to run Wondering if there would be an option to make the query client immediately restore some of the queries? |
Beta Was this translation helpful? Give feedback.
-
Here is my solution, in case it helps someone. It doesn't write to IndexedDB immediately after each request, instead doing it when there's idle time and no more than once every 10 seconds. It also doesn't wait more than a minute and guarantees writing to cache before unload and when changing tabs, so I feel like it's the best balance. import {
experimental_createPersister,
type AsyncStorage,
type PersistedQuery,
} from "@tanstack/query-persist-client-core";
import { QueryClient } from "@tanstack/react-query";
import { createStore, del, get, set, type UseStore } from "idb-keyval";
/**
* Creates an IndexedDB storage for React Query persistence
*
* This storage:
* - Groups writes together to improve performance
* - Uses browser idle time when available
* - Handles different queue sizes with different strategies
* - Ensures data is saved before page unload or tab switch
*
* @param idbStore - The IndexedDB store from idb-keyval
* @returns Storage compatible with TanStack Query persister
*/
function newIdbStorage(idbStore: UseStore): AsyncStorage<PersistedQuery> {
let pendingWrites = new Map<string, PersistedQuery>();
let isProcessing = false;
let idleCallbackId: number | null = null;
let maxDelayTimer: number | null = null;
let lastProcessTime = 0;
const MAX_DELAY = 60000; // 1 minute max before forced processing
const CRITICAL_THRESHOLD = 100; // Number of writes that triggers immediate processing
const THROTTLE_DELAY = 10000; // 10 seconds between processing
/**
* Process all pending writes to IndexedDB
*
* Steps:
* 1. Gather all pending writes
* 2. Clear scheduled timers
* 3. Write items to IndexedDB in parallel
* 4. Re-queue any failed writes
*/
const processPendingWrites = async () => {
if (pendingWrites.size === 0 || isProcessing) return;
const now = Date.now();
if (now - lastProcessTime < THROTTLE_DELAY) {
// Too soon to process again, reschedule
scheduleWrites();
return;
}
console.debug("Processing pending writes");
try {
isProcessing = true;
lastProcessTime = now;
const writes = Array.from(pendingWrites.entries());
pendingWrites.clear();
if (idleCallbackId) {
if ("requestIdleCallback" in window) window.cancelIdleCallback(idleCallbackId);
idleCallbackId = null;
}
if (maxDelayTimer) {
clearTimeout(maxDelayTimer);
maxDelayTimer = null;
}
await Promise.all(
writes.map(async ([key, value]) => {
try {
await set(key, value, idbStore);
} catch (error) {
console.error(`Failed to persist query for key ${key}:`, error);
// Re-add failed writes for retry
pendingWrites.set(key, value);
}
}),
);
} finally {
isProcessing = false;
if (pendingWrites.size > 0) scheduleWrites();
}
};
/**
* Schedule processing of pending writes
*
* Uses two ways:
* 1. requestIdleCallback for when browser is not busy
* 2. setTimeout as a backup to ensure it happens anyway
*/
const scheduleWrites = () => {
// Skip if already scheduled
if (idleCallbackId || maxDelayTimer) return;
const timeUntilNextProcess = Math.max(0, THROTTLE_DELAY - (Date.now() - lastProcessTime));
if ("requestIdleCallback" in window) {
idleCallbackId = window.requestIdleCallback(() => processPendingWrites(), {
timeout: MAX_DELAY, // Ensure callback runs within this time even if browser is busy
}) as unknown as number;
}
// Ensure we don't process more often than every 10 seconds
maxDelayTimer = window.setTimeout(processPendingWrites, Math.max(timeUntilNextProcess, MAX_DELAY));
};
// Set up event listeners for persistence guarantees
if (typeof window !== "undefined") {
// Process writes before page unload
window.addEventListener(
"beforeunload",
(event) => {
if (pendingWrites.size > 0) {
// May trigger browser confirmation in some browsers
event.preventDefault();
// Force processing regardless of throttle on page unload
lastProcessTime = 0;
processPendingWrites();
}
},
{ capture: true }, // Run before other handlers
);
// Process writes when tab becomes hidden
// Ensures data is saved when switching tabs or minimizing
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && pendingWrites.size > 0) {
// Force processing regardless of throttle when tab becomes hidden
lastProcessTime = 0;
processPendingWrites();
}
});
}
return {
getItem: async (key) => {
if (pendingWrites.has(key)) return pendingWrites.get(key);
return await get(key, idbStore);
},
/**
* Set an item in storage
*
* Handles storage differently based on queue size:
* 1. Small queue: Wait for idle time or up to 60s
* 2. Large queue (≥100 items): Save immediately
*/
setItem: async (key, value) => {
pendingWrites.set(key, value);
if (pendingWrites.size >= CRITICAL_THRESHOLD) {
// Process immediately when queue gets too large
// May briefly slow UI but prevents memory problems
const now = Date.now();
if (now - lastProcessTime >= THROTTLE_DELAY) {
await processPendingWrites();
} else {
scheduleWrites();
}
} else scheduleWrites();
},
removeItem: async (key) => {
pendingWrites.delete(key);
await del(key, idbStore);
},
};
}
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 30, // After 30 seconds read from persistence
persister:
typeof window === "undefined"
? undefined
: experimental_createPersister<PersistedQuery>({
storage: newIdbStorage(createStore("db_name", "store_name")),
maxAge: Infinity, // Keep persisted queries forever
serialize: (persistedQuery) => persistedQuery,
deserialize: (cached) => cached,
}),
},
},
}); |
Beta Was this translation helpful? Give feedback.
-
I think it might be a good idea to include this in experimental_createQueryPersister function newIdbStorage(idbStore: UseStore): AsyncStorage {
return {
getItem: async (key) => await get(key, idbStore),
setItem: async (key, value) => await set(key, value, idbStore),
removeItem: async (key) => await del(key, idbStore),
};
} It feels like it's basically recommended everywhere experimental_createQueryPersister is and if you define it slightly differently to this, typescript goes crazy |
Beta Was this translation helpful? Give feedback.
looks lovely