TanStack Query Architecture Deep Dive - Part 2: Advanced Patterns
🎯Learning Objectives
- 🎓Master request deduplication and caching strategies
- 🎓Implement optimistic updates with rollback mechanisms
- 🎓Build custom query management systems
- 🎓Apply TanStack Query patterns without the library
TanStack Query Advanced Patterns: Building Production-Ready Systems
This is Part 2 of our TanStack Query deep dive. Read Part 1 for the foundational patterns.
Request Deduplication: Preventing Redundant Network Calls
One of TanStack Query's most valuable features is its ability to deduplicate identical requests. When multiple components request the same data simultaneously, only one network request is made, and all components receive the same result. This seemingly simple feature requires sophisticated coordination.
The Deduplication Challenge
Consider this scenario: A user navigates to a product page that contains multiple components, each needing the same product data:
// Multiple components making the same request
const ProductHeader = ({ productId }) => {
const { data: product } = useQuery(['product', productId], fetchProduct);
return <h1>{product?.name}</h1>;
};
const ProductPrice = ({ productId }) => {
const { data: product } = useQuery(['product', productId], fetchProduct);
return <span>${product?.price}</span>;
};
const ProductDescription = ({ productId }) => {
const { data: product } = useQuery(['product', productId], fetchProduct);
return <p>{product?.description}</p>;
};
Without deduplication, this would trigger three identical API calls. TanStack Query solves this through a sophisticated promise-sharing mechanism:
// Request Deduplication Implementation
class QueryClient {
constructor() {
this.cache = new QueryCache();
this.mutationCache = new MutationCache();
this.queryDeduplication = new Map(); // queryHash -> Promise
}
async fetchQuery(options) {
const queryHash = this.generateQueryHash(options.queryKey);
// Check if this exact query is already in flight
if (this.queryDeduplication.has(queryHash)) {
return this.queryDeduplication.get(queryHash);
}
// Create the fetch promise
const fetchPromise = this.executeFetchQuery(options)
.finally(() => {
// Clean up the deduplication entry when complete
this.queryDeduplication.delete(queryHash);
});
// Store the promise for deduplication
this.queryDeduplication.set(queryHash, fetchPromise);
return fetchPromise;
}
async executeFetchQuery(options) {
const query = this.cache.get(
this.generateQueryHash(options.queryKey),
options.queryKey,
options
);
return query.fetch(options);
}
generateQueryHash(queryKey) {
// Generate a stable hash from the query key
return JSON.stringify(queryKey, (key, value) => {
if (typeof value === 'object' && value !== null) {
// Sort object keys for consistent hashing
return Object.keys(value)
.sort()
.reduce((sorted, key) => {
sorted[key] = value[key];
return sorted;
}, {});
}
return value;
});
}
}
Advanced Deduplication with Context Awareness
Real-world applications often need more nuanced deduplication strategies. Consider authentication contexts or user-specific data:
// Context-Aware Deduplication
class ContextAwareQueryClient extends QueryClient {
constructor() {
super();
this.contextualDeduplication = new Map(); // contextHash -> Map<queryHash, Promise>
}
async fetchQuery(options, context = {}) {
const contextHash = this.generateContextHash(context);
const queryHash = this.generateQueryHash(options.queryKey);
// Get or create context-specific deduplication map
if (!this.contextualDeduplication.has(contextHash)) {
this.contextualDeduplication.set(contextHash, new Map());
}
const contextQueries = this.contextualDeduplication.get(contextHash);
// Check for existing request in this context
if (contextQueries.has(queryHash)) {
return contextQueries.get(queryHash);
}
// Create new request with context
const fetchPromise = this.executeFetchQueryWithContext(options, context)
.finally(() => {
contextQueries.delete(queryHash);
// Clean up empty context maps
if (contextQueries.size === 0) {
this.contextualDeduplication.delete(contextHash);
}
});
contextQueries.set(queryHash, fetchPromise);
return fetchPromise;
}
generateContextHash(context) {
const { userId, orgId, permissions } = context;
return `user:${userId}-org:${orgId}-perms:${JSON.stringify(permissions)}`;
}
}
Optimistic Updates: The Art of Perceived Performance
Optimistic updates provide immediate feedback to users by updating the UI before the server confirms the change. This pattern dramatically improves perceived performance but requires careful error handling and rollback mechanisms.
Basic Optimistic Update Pattern
// Optimistic Update Implementation
const useOptimisticMutation = (mutationFn, options = {}) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
// Before mutation: apply optimistic update
onMutate: async (variables) => {
const { optimisticUpdate, queryKey } = options;
if (!optimisticUpdate || !queryKey) return {};
// Cancel outgoing refetches to avoid conflicts
await queryClient.cancelQueries(queryKey);
// Snapshot current data for rollback
const previousData = queryClient.getQueryData(queryKey);
// Apply optimistic update
queryClient.setQueryData(queryKey, (oldData) =>
optimisticUpdate(oldData, variables)
);
// Return context for rollback
return { previousData, queryKey };
},
// On error: rollback optimistic update
onError: (error, variables, context) => {
if (context?.previousData) {
queryClient.setQueryData(context.queryKey, context.previousData);
}
// Show error notification
options.onError?.(error, variables, context);
},
// On success: invalidate and refetch
onSuccess: (data, variables, context) => {
if (context?.queryKey) {
queryClient.invalidateQueries(context.queryKey);
}
options.onSuccess?.(data, variables, context);
}
});
};
// Usage Example
const useUpdatePost = () => {
return useOptimisticMutation(
updatePost,
{
queryKey: ['posts'],
optimisticUpdate: (posts, { id, updates }) =>
posts?.map(post =>
post.id === id ? { ...post, ...updates } : post
),
onError: (error) => {
toast.error('Failed to update post: ' + error.message);
}
}
);
};
Advanced Optimistic Updates with Conflict Resolution
Real applications often deal with concurrent updates from multiple sources. TanStack Query provides sophisticated patterns for handling these conflicts:
// Advanced Optimistic Update with Conflict Resolution
class OptimisticUpdateManager {
constructor(queryClient) {
this.queryClient = queryClient;
this.pendingUpdates = new Map(); // queryKey -> Set<updateId>
this.updateHistory = new Map(); // updateId -> UpdateRecord
}
async executeOptimisticUpdate({
queryKey,
mutationFn,
optimisticUpdate,
conflictResolution = 'lastWins'
}) {
const updateId = this.generateUpdateId();
// Track this update
this.trackUpdate(queryKey, updateId);
try {
// Apply optimistic update
const previousData = await this.applyOptimisticUpdate(
queryKey,
optimisticUpdate,
updateId
);
// Execute actual mutation
const result = await mutationFn();
// Handle successful completion
this.completeUpdate(updateId, result);
return result;
} catch (error) {
// Handle rollback with conflict resolution
await this.rollbackUpdate(updateId, conflictResolution);
throw error;
}
}
async applyOptimisticUpdate(queryKey, optimisticUpdate, updateId) {
// Cancel conflicting queries
await this.queryClient.cancelQueries(queryKey);
// Get current data
const currentData = this.queryClient.getQueryData(queryKey);
// Store update record
this.updateHistory.set(updateId, {
queryKey,
previousData: currentData,
timestamp: Date.now(),
status: 'pending'
});
// Apply optimistic update
const newData = optimisticUpdate(currentData);
this.queryClient.setQueryData(queryKey, newData);
return currentData;
}
async rollbackUpdate(updateId, conflictResolution) {
const updateRecord = this.updateHistory.get(updateId);
if (!updateRecord) return;
const { queryKey, previousData } = updateRecord;
// Get pending updates for this query
const pendingUpdates = Array.from(this.pendingUpdates.get(queryKey) || [])
.map(id => this.updateHistory.get(id))
.filter(record => record?.status === 'pending')
.sort((a, b) => a.timestamp - b.timestamp);
// Apply conflict resolution strategy
switch (conflictResolution) {
case 'lastWins':
// Keep only the most recent optimistic update
this.queryClient.setQueryData(queryKey,
pendingUpdates.length > 1
? this.reapplyUpdates(previousData, pendingUpdates.slice(-1))
: previousData
);
break;
case 'merge':
// Attempt to merge all pending updates
this.queryClient.setQueryData(queryKey,
this.mergeUpdates(previousData, pendingUpdates)
);
break;
case 'revert':
// Revert to original data and refetch
this.queryClient.setQueryData(queryKey, previousData);
await this.queryClient.invalidateQueries(queryKey);
break;
}
// Mark update as failed
updateRecord.status = 'failed';
this.untrackUpdate(queryKey, updateId);
}
mergeUpdates(baseData, updates) {
return updates.reduce((data, update) => {
try {
return update.optimisticUpdate(data);
} catch (error) {
console.warn('Failed to apply optimistic update during merge:', error);
return data;
}
}, baseData);
}
trackUpdate(queryKey, updateId) {
if (!this.pendingUpdates.has(queryKey)) {
this.pendingUpdates.set(queryKey, new Set());
}
this.pendingUpdates.get(queryKey).add(updateId);
}
untrackUpdate(queryKey, updateId) {
const updates = this.pendingUpdates.get(queryKey);
if (updates) {
updates.delete(updateId);
if (updates.size === 0) {
this.pendingUpdates.delete(queryKey);
}
}
}
generateUpdateId() {
return `update_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
Building Your Own Query System: Applying the Patterns
Now that we understand the core patterns, let's build a simplified but functional query system that incorporates these principles. This exercise will cement your understanding and provide a foundation for custom implementations.
Core Query System Implementation
// Custom Query System: Foundation
class SimpleQuerySystem {
constructor(options = {}) {
this.cache = new Map(); // queryKey -> QueryEntry
this.observers = new Map(); // queryKey -> Set<Observer>
this.defaults = {
staleTime: 0,
cacheTime: 5 * 60 * 1000, // 5 minutes
retry: 3,
retryDelay: (attempt) => Math.min(1000 * Math.pow(2, attempt), 30000),
...options
};
}
// Create a query hook
createQuery(queryKey, queryFn, options = {}) {
const config = { ...this.defaults, ...options };
const key = this.normalizeKey(queryKey);
return {
subscribe: (observer) => this.subscribe(key, observer),
fetch: () => this.fetch(key, queryFn, config),
invalidate: () => this.invalidate(key),
setData: (data) => this.setData(key, data),
getData: () => this.getData(key)
};
}
// React hook implementation
useQuery(queryKey, queryFn, options = {}) {
const [state, setState] = React.useState(() => {
const key = this.normalizeKey(queryKey);
return this.getQueryState(key);
});
React.useEffect(() => {
const key = this.normalizeKey(queryKey);
const query = this.createQuery(queryKey, queryFn, options);
// Subscribe to changes
const unsubscribe = query.subscribe((newState) => {
setState(newState);
});
// Fetch if needed
const entry = this.cache.get(key);
if (this.shouldFetch(entry, options)) {
query.fetch();
}
return unsubscribe;
}, [this.normalizeKey(queryKey)]);
return state;
}
subscribe(queryKey, observer) {
if (!this.observers.has(queryKey)) {
this.observers.set(queryKey, new Set());
}
this.observers.get(queryKey).add(observer);
return () => {
const observers = this.observers.get(queryKey);
if (observers) {
observers.delete(observer);
if (observers.size === 0) {
this.observers.delete(queryKey);
this.scheduleGarbageCollection(queryKey);
}
}
};
}
async fetch(queryKey, queryFn, config) {
const entry = this.getOrCreateEntry(queryKey, config);
// Prevent duplicate requests
if (entry.promise) {
return entry.promise;
}
// Update state to loading
this.updateEntry(queryKey, {
status: entry.data === undefined ? 'loading' : 'refreshing',
isFetching: true,
error: null
});
// Create fetch promise with retry logic
entry.promise = this.createFetchPromise(queryFn, config)
.then(data => {
this.updateEntry(queryKey, {
status: 'success',
data,
error: null,
dataUpdatedAt: Date.now(),
failureCount: 0,
isFetching: false
});
return data;
})
.catch(error => {
const newFailureCount = entry.failureCount + 1;
this.updateEntry(queryKey, {
status: 'error',
error,
errorUpdatedAt: Date.now(),
failureCount: newFailureCount,
isFetching: false
});
// Retry if configured
if (this.shouldRetry(newFailureCount, config.retry)) {
const delay = config.retryDelay(newFailureCount - 1);
setTimeout(() => {
this.fetch(queryKey, queryFn, config);
}, delay);
}
throw error;
})
.finally(() => {
const currentEntry = this.cache.get(queryKey);
if (currentEntry) {
currentEntry.promise = null;
}
});
return entry.promise;
}
async createFetchPromise(queryFn, config) {
const controller = new AbortController();
try {
const result = await queryFn({ signal: controller.signal });
return result;
} catch (error) {
if (controller.signal.aborted) {
throw new Error('Query cancelled');
}
throw error;
}
}
getOrCreateEntry(queryKey, config) {
let entry = this.cache.get(queryKey);
if (!entry) {
entry = {
status: 'idle',
data: undefined,
error: null,
dataUpdatedAt: 0,
errorUpdatedAt: 0,
failureCount: 0,
isFetching: false,
promise: null,
config
};
this.cache.set(queryKey, entry);
}
return entry;
}
updateEntry(queryKey, updates) {
const entry = this.cache.get(queryKey);
if (!entry) return;
Object.assign(entry, updates);
// Notify observers
const observers = this.observers.get(queryKey);
if (observers) {
const state = this.getQueryState(queryKey);
observers.forEach(observer => observer(state));
}
}
getQueryState(queryKey) {
const entry = this.cache.get(queryKey);
if (!entry) {
return {
status: 'idle',
data: undefined,
error: null,
isLoading: false,
isFetching: false,
isSuccess: false,
isError: false
};
}
return {
status: entry.status,
data: entry.data,
error: entry.error,
isLoading: entry.status === 'loading',
isFetching: entry.isFetching,
isSuccess: entry.status === 'success',
isError: entry.status === 'error',
dataUpdatedAt: entry.dataUpdatedAt,
errorUpdatedAt: entry.errorUpdatedAt,
failureCount: entry.failureCount
};
}
shouldFetch(entry, options) {
if (!entry || entry.data === undefined) return true;
const { staleTime = this.defaults.staleTime } = options;
const isStale = Date.now() - entry.dataUpdatedAt > staleTime;
return isStale;
}
shouldRetry(failureCount, retryConfig) {
if (typeof retryConfig === 'boolean') {
return retryConfig && failureCount < 3;
}
if (typeof retryConfig === 'number') {
return failureCount < retryConfig;
}
return failureCount < 3;
}
normalizeKey(queryKey) {
return Array.isArray(queryKey)
? JSON.stringify(queryKey)
: String(queryKey);
}
// Cache management
invalidate(queryKey) {
const entry = this.cache.get(queryKey);
if (entry) {
entry.dataUpdatedAt = 0; // Mark as stale
// Trigger refetch if there are active observers
const observers = this.observers.get(queryKey);
if (observers && observers.size > 0) {
// Refetch logic would go here
}
}
}
setData(queryKey, data) {
this.updateEntry(queryKey, {
status: 'success',
data,
dataUpdatedAt: Date.now()
});
}
getData(queryKey) {
const entry = this.cache.get(queryKey);
return entry?.data;
}
scheduleGarbageCollection(queryKey) {
setTimeout(() => {
const observers = this.observers.get(queryKey);
if (!observers || observers.size === 0) {
const entry = this.cache.get(queryKey);
if (entry) {
const { cacheTime } = entry.config || this.defaults;
const timeSinceUpdate = Date.now() - Math.max(
entry.dataUpdatedAt,
entry.errorUpdatedAt
);
if (timeSinceUpdate > cacheTime) {
this.cache.delete(queryKey);
}
}
}
}, 5000);
}
}
Usage Examples and Best Practices
// Create query system instance
const querySystem = new SimpleQuerySystem({
staleTime: 30000, // 30 seconds
cacheTime: 300000, // 5 minutes
retry: 2
});
// API functions
const fetchUser = async ({ signal }) => {
const response = await fetch('/api/user', { signal });
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
};
const fetchPosts = async ({ signal }) => {
const response = await fetch('/api/posts', { signal });
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
};
// React components using the custom system
const UserProfile = () => {
const { data: user, isLoading, error } = querySystem.useQuery(
['user'],
fetchUser,
{ staleTime: 60000 } // Override default stale time
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
};
const PostsList = () => {
const { data: posts, isLoading, error } = querySystem.useQuery(
['posts'],
fetchPosts
);
if (isLoading) return <div>Loading posts...</div>;
if (error) return <div>Error loading posts: {error.message}</div>;
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
);
};
Production Considerations and Advanced Patterns
When implementing these patterns in production applications, several additional considerations become crucial:
Memory Management and Performance
// Enhanced memory management
class ProductionQuerySystem extends SimpleQuerySystem {
constructor(options = {}) {
super(options);
this.memoryThreshold = options.memoryThreshold || 100; // Max cached queries
this.compressionEnabled = options.compression || false;
this.performanceMonitoring = options.monitoring || false;
}
updateEntry(queryKey, updates) {
super.updateEntry(queryKey, updates);
// Monitor memory usage
if (this.cache.size > this.memoryThreshold) {
this.performMemoryCleanup();
}
// Performance monitoring
if (this.performanceMonitoring) {
this.trackPerformanceMetrics(queryKey, updates);
}
}
performMemoryCleanup() {
const entries = Array.from(this.cache.entries());
// Sort by last access time (oldest first)
entries.sort(([, a], [, b]) => {
const aTime = Math.max(a.dataUpdatedAt, a.errorUpdatedAt);
const bTime = Math.max(b.dataUpdatedAt, b.errorUpdatedAt);
return aTime - bTime;
});
// Remove oldest entries that have no active observers
const toRemove = entries.slice(0, Math.floor(this.memoryThreshold * 0.2));
toRemove.forEach(([key]) => {
const observers = this.observers.get(key);
if (!observers || observers.size === 0) {
this.cache.delete(key);
}
});
}
trackPerformanceMetrics(queryKey, updates) {
// Implementation would depend on your monitoring solution
if (updates.dataUpdatedAt) {
const fetchTime = Date.now() - (updates.fetchStartTime || 0);
console.log(`Query ${queryKey} completed in ${fetchTime}ms`);
}
}
}
Error Boundary Integration
// Query Error Boundary
class QueryErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log error to monitoring service
console.error('Query Error Boundary caught an error:', error, errorInfo);
// Reset specific queries that may have caused the error
if (this.props.querySystem) {
this.props.querySystem.invalidateAll();
}
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div>
<h2>Something went wrong.</h2>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
Conclusion: Mastering the Patterns
TanStack Query's architecture represents a masterclass in solving complex state management challenges through elegant, composable patterns. By understanding and implementing these patterns ourselves, we gain several crucial advantages:
- •Deep Understanding: We comprehend the underlying mechanisms that make modern state management possible
- •Flexibility: We can adapt these patterns to specific use cases that may not fit standard library constraints
- •Performance Optimization: We can fine-tune implementations for specific performance requirements
- •Debugging Capability: We understand the system well enough to diagnose and fix complex issues
The patterns we've explored—the Observer pattern for reactivity, sophisticated caching strategies, intelligent request deduplication, and graceful error handling—are fundamental building blocks that extend far beyond any single library. These are the architectural principles that power the next generation of web applications.
Whether you use TanStack Query in production or implement these patterns yourself, the key is understanding that modern web applications require sophisticated approaches to state management. The complexity isn't incidental—it's the necessary sophistication required to deliver the seamless, responsive experiences users expect.
As web applications continue to evolve toward real-time, collaborative experiences with offline support and optimistic interactions, these patterns will only become more critical. Master them now, and you'll be prepared for whatever the future of web development brings.