TanStack Query supports persisting the query cache to external storage, allowing your app to restore cached data across page reloads and sessions.
Installation
# For localStorage/sessionStorage
pnpm add @tanstack/query-sync-storage-persister
# For AsyncStorage (React Native)
pnpm add @tanstack/query-async-storage-persister
# Core persistence package (required)
pnpm add @tanstack/query-persist-client-core
Basic Setup
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})
const persister = createSyncStoragePersister({
storage: window.localStorage,
})
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
<YourApp />
</PersistQueryClientProvider>
)
}
Storage Options
localStorage
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'TANSTACK_QUERY_CACHE', // Custom key (optional)
})
sessionStorage
const persister = createSyncStoragePersister({
storage: window.sessionStorage,
})
IndexedDB
For larger datasets:
import { experimental_createPersister } from '@tanstack/query-persist-client-core'
import { get, set, del } from 'idb-keyval'
const persister = experimental_createPersister({
storage: {
getItem: async (key) => await get(key),
setItem: async (key, value) => await set(key, value),
removeItem: async (key) => await del(key),
},
})
React Native AsyncStorage
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
})
Persist Options
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
buster: 'v1', // Version your cache
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === 'success'
},
},
hydrateOptions: {
// Options for restoring queries
},
}}
>
<App />
</PersistQueryClientProvider>
maxAge
Cache expiration time:
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24, // Discard cache older than 24 hours
}}
buster
Version your cache to invalidate old formats:
persistOptions={{
persister,
buster: 'v2', // Changing this invalidates all old caches
}}
Increment the buster value when making breaking changes to your data structure to automatically invalidate old cached data.
Custom Persister
Create a custom persister for any storage:
import { experimental_createPersister } from '@tanstack/query-persist-client-core'
const customPersister = experimental_createPersister({
storage: {
getItem: async (key: string) => {
// Implement your custom get logic
const data = await yourCustomStorage.get(key)
return data
},
setItem: async (key: string, value: string) => {
// Implement your custom set logic
await yourCustomStorage.set(key, value)
},
removeItem: async (key: string) => {
// Implement your custom remove logic
await yourCustomStorage.remove(key)
},
},
})
Selective Persistence
Choose which queries to persist:
persistOptions={{
persister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist queries with specific keys
const queryKey = query.queryKey[0]
return ['posts', 'users', 'comments'].includes(queryKey as string)
},
},
}}
Exclude Sensitive Data
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Don't persist sensitive queries
const queryKey = query.queryKey[0]
const sensitiveKeys = ['user-credentials', 'payment-info', 'auth-token']
return !sensitiveKeys.includes(queryKey as string)
},
}
Only Persist Recent Queries
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
const HOUR = 1000 * 60 * 60
const queryAge = Date.now() - query.state.dataUpdatedAt
return queryAge < HOUR // Only persist queries less than 1 hour old
},
}
Data Serialization
Transform data during persist/restore:
persistOptions={{
persister,
dehydrateOptions: {
serializeData: (data) => {
// Custom serialization (e.g., handle Dates)
return JSON.stringify(data, (key, value) => {
if (value instanceof Date) {
return { __type: 'Date', value: value.toISOString() }
}
return value
})
},
},
hydrateOptions: {
deserializeData: (data) => {
// Custom deserialization
return JSON.parse(data, (key, value) => {
if (value?.__type === 'Date') {
return new Date(value.value)
}
return value
})
},
},
}}
Manual Persistence
For more control, use the persistence functions directly:
import {
persistQueryClient,
persistQueryClientSave,
persistQueryClientRestore,
} from '@tanstack/query-persist-client-core'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
const queryClient = new QueryClient()
const persister = createSyncStoragePersister({
storage: window.localStorage,
})
// Restore on app start
await persistQueryClientRestore({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24,
})
// Manually save
await persistQueryClientSave({
queryClient,
persister,
})
// Auto-persist on cache changes
const unsubscribe = persistQueryClientSubscribe({
queryClient,
persister,
})
// Clean up
unsubscribe()
Persist Mutations
Persist paused mutations (e.g., offline support):
dehydrateOptions: {
shouldDehydrateMutation: (mutation) => {
// Persist paused mutations (offline mutations)
return mutation.state.isPaused
},
}
Error Handling
Handle persistence errors:
import { persistQueryClient } from '@tanstack/query-persist-client-core'
try {
await persistQueryClientRestore({
queryClient,
persister,
})
} catch (error) {
console.error('Failed to restore cache:', error)
// Fall back to empty cache
}
Storage Quota Management
Handle storage quota exceeded:
const persister = experimental_createPersister({
storage: {
setItem: async (key, value) => {
try {
localStorage.setItem(key, value)
} catch (error) {
if (error.name === 'QuotaExceededError') {
// Clear old data
localStorage.removeItem(key)
// Try again
localStorage.setItem(key, value)
}
}
},
getItem: (key) => localStorage.getItem(key),
removeItem: (key) => localStorage.removeItem(key),
},
})
Compression
Compress persisted data to save space:
import LZString from 'lz-string'
const persister = experimental_createPersister({
storage: {
setItem: async (key, value) => {
const compressed = LZString.compress(value)
localStorage.setItem(key, compressed)
},
getItem: async (key) => {
const compressed = localStorage.getItem(key)
return compressed ? LZString.decompress(compressed) : null
},
removeItem: (key) => localStorage.removeItem(key),
},
})
Multi-Tab Synchronization
Sync cache across browser tabs:
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental'
const queryClient = new QueryClient()
// Enable cross-tab sync
broadcastQueryClient({
queryClient,
broadcastChannel: 'my-app-cache',
})
Testing with Persistence
import { QueryClient } from '@tanstack/react-query'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { persistQueryClient } from '@tanstack/query-persist-client-core'
test('should persist and restore queries', async () => {
const queryClient = new QueryClient()
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'TEST_CACHE',
})
// Add data to cache
queryClient.setQueryData(['test'], { value: 'data' })
// Persist
await persistQueryClientSave({ queryClient, persister })
// Create new client
const newQueryClient = new QueryClient()
// Restore
await persistQueryClientRestore({
queryClient: newQueryClient,
persister,
})
// Verify data restored
expect(newQueryClient.getQueryData(['test'])).toEqual({ value: 'data' })
// Cleanup
localStorage.removeItem('TEST_CACHE')
})
Best Practices
- Set appropriate maxAge - Don’t persist stale data indefinitely
- Use cache busting - Version your cache for breaking changes
- Exclude sensitive data - Never persist auth tokens, passwords, etc.
- Handle errors gracefully - App should work even if restoration fails
- Monitor storage size - Especially important for mobile apps
- Compress large datasets - Use compression for better performance
Common Pitfalls
1. Not Setting gcTime
// ❌ Bad - Queries removed from cache before persistence
const queryClient = new QueryClient() // default gcTime is 5 minutes
// ✅ Good - Keep queries in cache long enough to persist
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24 hours
},
},
})
2. Persisting Everything
// ❌ Bad - Persists all queries including sensitive/temporary data
persistOptions={{ persister }}
// ✅ Good - Only persist what you need
persistOptions={{
persister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Selective persistence
return query.state.status === 'success' && !query.meta?.sensitive
},
},
}}
3. Forgetting Cache Version
// ❌ Bad - Old cache format breaks new app version
persistOptions={{ persister }}
// ✅ Good - Version your cache
persistOptions={{
persister,
buster: 'v1', // Increment when data structure changes
}}
Next Steps
- SSR - Combine persistence with server-side rendering