logoAffluent docs

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

  1. Query client usage:

    • Use getCachedQueryClient() in server components
    • Avoid getQueryClient() unless you specifically need a fresh client
  2. Data loading:

    • Use ensureQueryData() instead of fetchQuery() for better caching
    • Prefer parallel loading with Promise.all() when possible
    • Always handle loading and error states
  3. Query keys:

    • Use the provided query key helpers for consistency
    • Include relevant parameters in query keys
    • Keep query key structure flat for better debugging
  4. Caching strategy:

    • Use appropriate stale times based on data volatility
    • Consider using stableQueryOptions for 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

  1. Add query key helper to appropriate query file
  2. Define query options with proper types
  3. Choose appropriate caching configuration
  4. Add to parallel loading if needed
  5. 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