Data fetching and caching
Our implementation of data fetching and caching using TanStack Query, optimized for Next.js Server Components and client-side caching.
Overview
Our data fetching system is built on TanStack Query with optimizations for Next.js Server Components. It provides:
- Consistent caching behavior across server and client components
- Efficient data loading with minimal redundant fetches
- Type-safe query definitions and results
- Centralized query configuration
- Performance monitoring through query execution logging
Query client configuration
The query client is configured in query-client.ts with two main functions:
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
retry: 1,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
},
});
}
// Server: new client per render
export function getQueryClient() {
if (isServer) return makeQueryClient();
// Browser: singleton client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
// Cached client using React's cache()
export const getCachedQueryClient = cache(getQueryClient);Key features
- Different client behavior for server and browser environments
- Configurable stale time and garbage collection
- Optimized dehydration for pending queries
- Cached query client for server components
Core data Loading
Core data is loaded through load-core-data.ts and provides essential user and space information:
export async function loadCoreData(
queryClient: QueryClient
): Promise<CoreData> {
const coreData = await queryClient.ensureQueryData(coreQueries.coreData());
return coreData.data;
}Core queries
Core queries are defined in core.query.ts:
export const coreQueries = {
all: () => ["core-data"] as const,
users: () => ["users"] as const,
profiles: () => ["profiles"] as const,
spaces: () => ["spaces"] as const,
coreData: () =>
queryOptions<CoreDataQueryResult>({
queryKey: coreQueries.all(),
queryFn: async () => {
// Sequential fetching of user -> profile -> space
},
}),
};Wealth data loading
Wealth data loading (load-wealth-data.ts) handles financial data with performance tracking:
export async function loadWealthData(queryClient: QueryClient) {
// Get core data from cache
const coreData = queryClient.getQueryData(coreQueries.coreData().queryKey);
// Parallel loading of financial data
await Promise.all([
logQueryExecution(
queryClient,
wealthQueries.allInstitutions(),
"Institutions"
),
logQueryExecution(
queryClient,
wealthQueries.assetsBySpace(spaceId),
"Assets"
),
// ... other queries
]);
}Performance monitoring
Each query execution is logged with timing:
async function logQueryExecution<T>(
queryClient: QueryClient,
param: FetchQueryOptions<T, Error, T>,
queryName: string
) {
const startTime = performance.now();
const result = await queryClient.fetchQuery(param);
logger(`${queryName}: 🌐 ${duration}ms`);
return result;
}Wealth queries
Wealth queries (wealth.query.ts) provide a comprehensive set of financial data queries:
export const wealthQueries = {
// Base keys for invalidation
all: () => ["wealth"] as const,
datapoints: () => [...wealthQueries.all(), "datapoints"] as const,
// Query configurations
assetsBySpace: (spaceId: string) =>
queryOptions<AssetsQueryResult>({
queryKey: [...wealthQueries.all(), "assets", spaceId],
queryFn: () => getAssetsBySpaceId(spaceId),
...generalQueryOptions,
}),
// ... other queries
};Query options
Two levels of caching configuration:
const generalQueryOptions = {
staleTime: 1000 * 30, // 30 seconds
gcTime: 1000 * 60 * 5, // 5 minutes
placeholderData: keepPreviousData,
keepPreviousData: true,
};
const stableQueryOptions = {
...generalQueryOptions,
staleTime: 1000 * 60 * 60 * 24, // 24 hours
gcTime: 1000 * 60 * 60 * 24, // 24 hours
};Implementation patterns
Server components
For server components, use the cached query client:
async function LoadComponent() {
const queryClient = getCachedQueryClient();
const coreData = await queryClient.ensureQueryData(coreQueries.coreData());
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ClientComponent />
</HydrationBoundary>
);
}Client components
Client components use hooks to access cached data:
function ClientComponent() {
const { data: coreData } = useQuery(coreQueries.coreData());
// ... use data
}Best practices
-
Query client usage:
- Use
getCachedQueryClient()in server components - Avoid
getQueryClient()unless you specifically need a fresh client
- Use
-
Data loading:
- Use
ensureQueryData()instead offetchQuery()for better caching - Prefer parallel loading with
Promise.all()when possible - Always handle loading and error states
- Use
-
Query keys:
- Use the provided query key helpers for consistency
- Include relevant parameters in query keys
- Keep query key structure flat for better debugging
-
Caching strategy:
- Use appropriate stale times based on data volatility
- Consider using
stableQueryOptionsfor rarely changing data - Implement proper cache invalidation when data changes
Maintenance
Cache invalidation
Use the exported key helpers for targeted invalidation:
queryClient.invalidateQueries({ queryKey: ASSETS_KEY(spaceId) });Adding new queries
- Add query key helper to appropriate query file
- Define query options with proper types
- Choose appropriate caching configuration
- Add to parallel loading if needed
- Update documentation
Security considerations
- Ensure proper data access scoping
- Validate query parameters server-side
- Handle sensitive data appropriately
- Use type-safe query definitions
- Implement proper error handling