Skip to main content

Caching

The @54vie/api package provides comprehensive caching capabilities built on TanStack Query, including intelligent cache management, cache invalidation strategies, and offline support for building resilient applications.

Installation

npm install @54vie/api @tanstack/react-query
# or
yarn add @54vie/api @tanstack/react-query
# or
pnpm add @54vie/api @tanstack/react-query

TypeScript Interfaces

interface CacheConfig {
defaultStaleTime?: number;
defaultGcTime?: number;
maxAge?: number;
storage?: CacheStorage;
encryption?: EncryptionConfig;
compression?: boolean;
}

interface CacheStorage {
getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>;
removeItem: (key: string) => Promise<void>;
clear: () => Promise<void>;
keys: () => Promise<string[]>;
}

interface EncryptionConfig {
enabled: boolean;
key?: string | CryptoKey;
algorithm?: 'AES-GCM' | 'AES-CBC';
}

interface CacheEntry<T = unknown> {
data: T;
timestamp: number;
maxAge?: number;
etag?: string;
lastModified?: string;
}

interface CacheOptions {
staleTime?: number;
gcTime?: number;
cacheTime?: number; // Alias for gcTime
persist?: boolean;
tags?: string[];
priority?: 'low' | 'normal' | 'high';
}

interface InvalidationOptions {
exact?: boolean;
refetchType?: 'active' | 'inactive' | 'all' | 'none';
throwOnError?: boolean;
cancelRefetch?: boolean;
}

interface PersistConfig {
storage: CacheStorage;
key?: string;
maxAge?: number;
serialize?: (data: unknown) => string;
deserialize?: (data: string) => unknown;
filter?: (query: Query) => boolean;
buster?: string;
}

interface OfflineConfig {
enabled?: boolean;
storage?: CacheStorage;
retryOnReconnect?: boolean;
syncOnReconnect?: boolean;
mutationQueue?: MutationQueueConfig;
}

interface MutationQueueConfig {
maxSize?: number;
maxAge?: number;
retryCount?: number;
onSync?: (mutation: QueuedMutation) => void;
onSyncError?: (mutation: QueuedMutation, error: Error) => void;
}

interface QueuedMutation {
id: string;
mutationKey: unknown[];
variables: unknown;
timestamp: number;
retryCount: number;
}

interface Query {
queryKey: unknown[];
queryHash: string;
state: QueryState;
}

interface QueryState<TData = unknown, TError = unknown> {
data: TData | undefined;
dataUpdatedAt: number;
error: TError | null;
errorUpdatedAt: number;
status: 'pending' | 'error' | 'success';
fetchStatus: 'fetching' | 'paused' | 'idle';
}

Cache Configuration

Basic Setup

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 3,
},
},
});

// Optional: Persist cache to localStorage
const persister = createSyncStoragePersister({
storage: window.localStorage,
});

persistQueryClient({
queryClient,
persister,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
});

function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}

Cache Storage Options

// In-memory cache (default)
const memoryStorage: CacheStorage = {
cache: new Map<string, string>(),
getItem: async (key) => this.cache.get(key) ?? null,
setItem: async (key, value) => this.cache.set(key, value),
removeItem: async (key) => this.cache.delete(key),
clear: async () => this.cache.clear(),
keys: async () => Array.from(this.cache.keys()),
};

// localStorage
const localStorageCache: CacheStorage = {
getItem: async (key) => localStorage.getItem(key),
setItem: async (key, value) => localStorage.setItem(key, value),
removeItem: async (key) => localStorage.removeItem(key),
clear: async () => localStorage.clear(),
keys: async () => Object.keys(localStorage),
};

// IndexedDB (for larger data)
import { get, set, del, clear, keys } from 'idb-keyval';

const indexedDBStorage: CacheStorage = {
getItem: async (key) => get(key),
setItem: async (key, value) => set(key, value),
removeItem: async (key) => del(key),
clear: async () => clear(),
keys: async () => keys(),
};

Caching Strategies

Stale-While-Revalidate

import { useQuery } from '@54vie/api';

function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, isFetching } = useQuery<User>({
queryKey: ['users', userId],
url: `/users/${userId}`,
// Data is fresh for 5 minutes
staleTime: 5 * 60 * 1000,
// Keep in cache for 30 minutes
gcTime: 30 * 60 * 1000,
});

return (
<div>
{/* Show cached data immediately, refetch in background */}
{isFetching && !isLoading && <RefreshIndicator />}
{data && <UserCard user={data} />}
</div>
);
}

Cache-First

function CriticalData() {
const { data } = useQuery<Config>({
queryKey: ['config'],
url: '/config',
// Data never goes stale automatically
staleTime: Infinity,
// Manual refresh only
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});

return <ConfigDisplay config={data} />;
}

Network-First

function RealTimeData() {
const { data } = useQuery<Stats>({
queryKey: ['stats'],
url: '/stats',
// Data is immediately stale
staleTime: 0,
// Always fetch fresh data
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
});

return <StatsDisplay stats={data} />;
}

Time-Based Caching

// Short cache for frequently changing data
const { data: notifications } = useQuery({
queryKey: ['notifications'],
url: '/notifications',
staleTime: 30 * 1000, // 30 seconds
refetchInterval: 60 * 1000, // Refetch every minute
});

// Long cache for static data
const { data: countries } = useQuery({
queryKey: ['countries'],
url: '/countries',
staleTime: 24 * 60 * 60 * 1000, // 24 hours
gcTime: 7 * 24 * 60 * 60 * 1000, // 7 days
});

Cache Invalidation

Basic Invalidation

import { useQueryClient } from '@tanstack/react-query';

function useInvalidateUsers() {
const queryClient = useQueryClient();

return {
// Invalidate all user queries
invalidateAll: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},

// Invalidate specific user
invalidateUser: (userId: string) => {
queryClient.invalidateQueries({ queryKey: ['users', userId] });
},

// Invalidate with exact match
invalidateExact: (userId: string) => {
queryClient.invalidateQueries({
queryKey: ['users', userId],
exact: true,
});
},
};
}

Invalidation After Mutation

function useCreateUser() {
const queryClient = useQueryClient();

return useMutation({
url: '/users',
method: 'POST',
onSuccess: () => {
// Invalidate users list to refetch
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}

function useUpdateUser() {
const queryClient = useQueryClient();

return useMutation({
url: (vars) => `/users/${vars.id}`,
method: 'PATCH',
onSuccess: (data, variables) => {
// Update cache directly
queryClient.setQueryData(['users', variables.id], data);
// Invalidate list
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}

Tag-Based Invalidation

import { createQueryKeyStore } from '@54vie/api';

const queryKeys = createQueryKeyStore({
users: {
all: ['users'] as const,
list: (filters: UserFilters) => ['users', 'list', filters] as const,
detail: (id: string) => ['users', 'detail', id] as const,
},
posts: {
all: ['posts'] as const,
list: (filters: PostFilters) => ['posts', 'list', filters] as const,
detail: (id: string) => ['posts', 'detail', id] as const,
byUser: (userId: string) => ['posts', 'user', userId] as const,
},
});

// Usage
function useInvalidatePosts() {
const queryClient = useQueryClient();

return {
invalidateAll: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.posts.all });
},
invalidateUserPosts: (userId: string) => {
queryClient.invalidateQueries({
queryKey: queryKeys.posts.byUser(userId),
});
},
};
}

Predicate-Based Invalidation

const queryClient = useQueryClient();

// Invalidate all queries matching a predicate
queryClient.invalidateQueries({
predicate: (query) => {
// Invalidate all user-related queries
return query.queryKey[0] === 'users';
},
});

// Invalidate stale queries only
queryClient.invalidateQueries({
predicate: (query) => {
return query.state.isStale;
},
});

// Invalidate queries with errors
queryClient.invalidateQueries({
predicate: (query) => {
return query.state.status === 'error';
},
});

Manual Cache Updates

Setting Cache Data

const queryClient = useQueryClient();

// Set data for a specific query
queryClient.setQueryData(['users', userId], updatedUser);

// Update with previous data
queryClient.setQueryData<User[]>(['users'], (oldData) => {
if (!oldData) return [newUser];
return [...oldData, newUser];
});

// Set data with options
queryClient.setQueryData(
['users', userId],
updatedUser,
{ updatedAt: Date.now() }
);

Optimistic Updates

function useUpdateTodo() {
const queryClient = useQueryClient();

return useMutation({
url: (vars) => `/todos/${vars.id}`,
method: 'PATCH',
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });

// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

// Optimistically update
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === newTodo.id ? { ...todo, ...newTodo } : todo
)
);

return { previousTodos };
},
onError: (err, newTodo, context) => {
// Rollback on error
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}

Prefetching

const queryClient = useQueryClient();

// Prefetch on hover
function UserLink({ userId }: { userId: string }) {
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => api.get(`/users/${userId}`),
staleTime: 5 * 60 * 1000,
});
};

return (
<Link
to={`/users/${userId}`}
onMouseEnter={prefetchUser}
onFocus={prefetchUser}
>
View User
</Link>
);
}

// Prefetch on route change
async function prefetchRouteData(route: string) {
if (route.startsWith('/users/')) {
const userId = route.split('/')[2];
await queryClient.prefetchQuery({
queryKey: ['users', userId],
queryFn: () => api.get(`/users/${userId}`),
});
}
}

Offline Support

Basic Offline Setup

import { onlineManager } from '@tanstack/react-query';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import AsyncStorage from '@react-native-async-storage/async-storage';

// Create persister for offline storage
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
});

// Configure online manager
onlineManager.setEventListener((setOnline) => {
return window.addEventListener('online', () => setOnline(true));
});

// Persist query client
persistQueryClient({
queryClient,
persister: asyncStoragePersister,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});

Offline Mutation Queue

import { useMutation, useQueryClient, onlineManager } from '@tanstack/react-query';

interface MutationQueue {
id: string;
mutationFn: () => Promise<unknown>;
timestamp: number;
}

const mutationQueue: MutationQueue[] = [];

function useOfflineMutation<TData, TVariables>({
mutationFn,
onSuccess,
onError,
}: {
mutationFn: (variables: TVariables) => Promise<TData>;
onSuccess?: (data: TData) => void;
onError?: (error: Error) => void;
}) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (variables: TVariables) => {
if (!onlineManager.isOnline()) {
// Queue mutation for later
const mutation = {
id: crypto.randomUUID(),
mutationFn: () => mutationFn(variables),
timestamp: Date.now(),
};

mutationQueue.push(mutation);
await saveMutationQueue(mutationQueue);

throw new Error('Offline - mutation queued');
}

return mutationFn(variables);
},
onSuccess,
onError,
});
}

// Sync queued mutations when online
window.addEventListener('online', async () => {
const queue = await loadMutationQueue();

for (const mutation of queue) {
try {
await mutation.mutationFn();
// Remove from queue on success
queue.splice(queue.indexOf(mutation), 1);
} catch (error) {
console.error('Failed to sync mutation:', error);
}
}

await saveMutationQueue(queue);
});

Network Status Hook

import { useOnlineManager } from '@tanstack/react-query';

function NetworkStatus() {
const isOnline = useOnlineManager().isOnline();

if (!isOnline) {
return (
<div className="offline-banner">
You are offline. Changes will be synced when you reconnect.
</div>
);
}

return null;
}

Optimistic Offline Updates

function useOfflineTodoToggle() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async (todo: Todo) => {
// Store mutation for offline sync
await storePendingMutation('todo:toggle', todo);

// Make API call if online
if (navigator.onLine) {
return api.patch(`/todos/${todo.id}`, {
completed: !todo.completed,
});
}

// Return optimistic data
return { ...todo, completed: !todo.completed };
},
onMutate: async (todo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });

const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);

queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
);

return { previousTodos };
},
onError: (err, todo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
});
}

Cache Persistence

localStorage Persistence

import {
persistQueryClient,
PersistQueryClientProvider,
} from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'REACT_QUERY_CACHE',
throttleTime: 1000,
serialize: (data) => JSON.stringify(data),
deserialize: (data) => JSON.parse(data),
});

function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
buster: 'v1', // Cache version, change to invalidate
}}
>
<YourApp />
</PersistQueryClientProvider>
);
}

Selective Persistence

const persister = createSyncStoragePersister({
storage: window.localStorage,
});

persistQueryClient({
queryClient,
persister,
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
if (query.state.status !== 'success') return false;

// Only persist specific queries
const key = query.queryKey[0];
const persistableKeys = ['users', 'config', 'preferences'];

return persistableKeys.includes(key as string);
},
},
});

Encrypted Persistence

async function encryptData(data: string, key: CryptoKey): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encodedData = new TextEncoder().encode(data);

const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encodedData
);

const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);

return btoa(String.fromCharCode(...combined));
}

async function decryptData(data: string, key: CryptoKey): Promise<string> {
const combined = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);

const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
encrypted
);

return new TextDecoder().decode(decrypted);
}

const encryptedPersister = createSyncStoragePersister({
storage: window.localStorage,
serialize: async (data) => encryptData(JSON.stringify(data), encryptionKey),
deserialize: async (data) => JSON.parse(await decryptData(data, encryptionKey)),
});

Cache Garbage Collection

const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Garbage collect unused queries after 5 minutes
gcTime: 5 * 60 * 1000,
},
},
});

// Manual garbage collection
queryClient.getQueryCache().clear();

// Remove specific queries
queryClient.removeQueries({ queryKey: ['users'] });

// Remove inactive queries only
queryClient.removeQueries({
predicate: (query) => {
return query.getObserversCount() === 0;
},
});

Best Practices

  1. Set appropriate staleTime - Balance between data freshness and network requests
  2. Use gcTime wisely - Keep data in memory for quick access, but not indefinitely
  3. Implement optimistic updates - Provide instant feedback for mutations
  4. Persist critical data - Use localStorage/IndexedDB for offline support
  5. Invalidate strategically - Avoid over-invalidation that causes unnecessary refetches
  6. Use query key factories - Maintain consistent and type-safe query keys