Back to Blog

Data Fetching as a System Design Problem in React

Not React Query vs SWR. Instead: centralized vs decentralized fetching, ownership boundaries, Server Components vs client fetching, waterfall avoidance, and data dependency graphs. Architectural patterns for data flow at scale.


The Data Fetching Architecture Problem

As applications grow, data fetching becomes a system design problem:

┌─────────────────────────────────────────────────────────────────┐
│                 Data Fetching Complexity at Scale                │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Small App (5 components):                                      │
│  └── Each component fetches its own data → Simple               │
│                                                                  │
│  Medium App (50 components):                                    │
│  ├── Duplicate requests for same data                          │
│  ├── Inconsistent loading states                               │
│  ├── Props drilling or Context soup                            │
│  └── Unpredictable performance                                 │
│                                                                  │
│  Large App (500+ components):                                   │
│  ├── N+1 request patterns                                      │
│  ├── Waterfall request chains                                  │
│  ├── Race conditions                                           │
│  ├── Stale data across views                                   │
│  ├── Memory leaks from orphaned queries                        │
│  ├── Unclear data ownership                                    │
│  └── Testing nightmares                                        │
│                                                                  │
│  Questions you must answer:                                     │
│  • Who owns fetching product data?                              │
│  • Where does cart state live?                                  │
│  • How do we avoid 50 components all fetching user data?       │
│  • What happens when data dependencies are circular?           │
│  • How do we prefetch data for likely navigations?             │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Centralized vs Decentralized Fetching

Decentralized: Components Fetch Their Own Data

// Each component is responsible for its own data

function ProductCard({ productId }: { productId: string }) {
  const { data: product } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
  });

  const { data: inventory } = useQuery({
    queryKey: ['inventory', productId],
    queryFn: () => fetchInventory(productId),
  });

  const { data: reviews } = useQuery({
    queryKey: ['reviews', productId],
    queryFn: () => fetchReviews(productId),
  });

  return (
    <div>
      <h2>{product?.name}</h2>
      <InventoryBadge stock={inventory?.stock} />
      <StarRating rating={reviews?.average} />
    </div>
  );
}

// Pros:
// ✓ Components are self-contained
// ✓ Easy to understand data needs per component
// ✓ Move component → data fetching moves with it

// Cons:
// ✗ List of 50 ProductCards = potentially 150 parallel requests
// ✗ No coordination between components
// ✗ Duplicate requests if same product appears twice
// ✗ Hard to optimize loading sequences

Centralized: Container Fetches, Children Render

// Container owns all data fetching for its subtree

function ProductListContainer({ categoryId }: { categoryId: string }) {
  // Single query fetches everything needed
  const { data, isLoading } = useQuery({
    queryKey: ['category-products', categoryId],
    queryFn: () => fetchCategoryWithProducts(categoryId),
  });

  if (isLoading) return <ProductListSkeleton />;

  return (
    <ProductList
      products={data.products}
      inventories={data.inventories}
      reviews={data.reviews}
    />
  );
}

// Pure presentational component
function ProductList({
  products,
  inventories,
  reviews,
}: ProductListProps) {
  return (
    <div>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          inventory={inventories[product.id]}
          reviews={reviews[product.id]}
        />
      ))}
    </div>
  );
}

// Pros:
// ✓ Single request fetches all data
// ✓ Coordinated loading state
// ✓ Optimized query (backend can batch)
// ✓ Clear ownership

// Cons:
// ✗ ProductCard is no longer self-contained
// ✗ Container becomes a god object
// ✗ Harder to reuse ProductCard elsewhere
// ✗ Change in ProductCard data needs → change in Container

Hybrid: Smart Boundaries with Shared Cache

// Data fetching boundaries with shared cache

// Route-level prefetching
async function ProductListPage({ params }: { params: { category: string } }) {
  // Prefetch at route level
  await queryClient.prefetchQuery({
    queryKey: ['category-products', params.category],
    queryFn: () => fetchCategoryProducts(params.category),
  });

  return <ProductListContainer categoryId={params.category} />;
}

// Container provides context, doesn't fetch
function ProductListContainer({ categoryId }: { categoryId: string }) {
  // Data already prefetched, this is instant
  const { data } = useQuery({
    queryKey: ['category-products', categoryId],
    queryFn: () => fetchCategoryProducts(categoryId),
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 min
  });

  return (
    <ProductListContext.Provider value={data}>
      <ProductList productIds={data.productIds} />
    </ProductListContext.Provider>
  );
}

// Components fetch their own data, but dedupe via cache
function ProductCard({ productId }: { productId: string }) {
  // These will be cache hits if container prefetched
  // Or fresh fetches if component is used standalone
  const { data: product } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
    staleTime: 5 * 60 * 1000,
  });

  // Can still be self-contained
  const { data: inventory } = useQuery({
    queryKey: ['inventory', productId],
    queryFn: () => fetchInventory(productId),
    staleTime: 30 * 1000, // Inventory changes more often
  });

  return <div>{/* ... */}</div>;
}

// Best of both worlds:
// ✓ Components are reusable and self-contained
// ✓ Smart prefetching at route boundaries
// ✓ Deduplication via shared cache
// ✓ Flexible loading patterns

Data Ownership Boundaries

Clear Ownership Model

┌─────────────────────────────────────────────────────────────────┐
│                    Data Ownership Boundaries                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Level 1: Global (App-wide)                                     │
│  Owner: Root layout / Auth provider                             │
│  Data: Current user, permissions, global settings               │
│  Lifecycle: Entire session                                      │
│                                                                  │
│  Level 2: Route (Page-wide)                                     │
│  Owner: Page component / Route loader                           │
│  Data: Primary entity (product, order, etc.)                    │
│  Lifecycle: Route navigation                                    │
│                                                                  │
│  Level 3: Section (Feature-wide)                                │
│  Owner: Feature container                                       │
│  Data: Feature-specific (reviews, recommendations)              │
│  Lifecycle: Feature visibility                                  │
│                                                                  │
│  Level 4: Component (Local)                                     │
│  Owner: Individual component                                    │
│  Data: Component state, derived data                            │
│  Lifecycle: Component mount                                     │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Implementation

// Level 1: Global data provider
// app/providers.tsx

export function AppProviders({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <PermissionsProvider>
          <SettingsProvider>
            {children}
          </SettingsProvider>
        </PermissionsProvider>
      </AuthProvider>
    </QueryClientProvider>
  );
}

function AuthProvider({ children }: { children: React.ReactNode }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchCurrentUser,
    staleTime: Infinity, // Only refetch manually
    gcTime: Infinity,    // Never garbage collect
  });

  if (isLoading) return <AppLoadingSkeleton />;

  return (
    <AuthContext.Provider value={user}>
      {children}
    </AuthContext.Provider>
  );
}

// Level 2: Route-level data
// app/products/[id]/page.tsx

export default async function ProductPage({ params }: { params: { id: string } }) {
  // Route owns primary entity fetch
  const product = await fetchProduct(params.id);

  return (
    <ProductContext.Provider value={product}>
      <ProductHeader />
      <ProductDetails />
      <ProductSections />
    </ProductContext.Provider>
  );
}

// Level 3: Section-level data
// components/product-reviews.tsx

function ProductReviews() {
  const product = useProductContext();

  // Section owns its data
  const { data: reviews } = useQuery({
    queryKey: ['reviews', product.id],
    queryFn: () => fetchReviews(product.id),
  });

  return (
    <ReviewsContext.Provider value={reviews}>
      <ReviewsSummary />
      <ReviewsList />
      <ReviewForm />
    </ReviewsContext.Provider>
  );
}

// Level 4: Component-level data
// components/review-author.tsx

function ReviewAuthor({ userId }: { userId: string }) {
  // Component owns its own supplementary data
  const { data: author } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  return <span>{author?.name}</span>;
}

Server Components vs Client Fetching

Decision Framework

┌─────────────────────────────────────────────────────────────────┐
│              Server vs Client Fetching Decision                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Use Server Components when:                                    │
│  ├── Data is needed for initial render (SEO, LCP)              │
│  ├── Data doesn't change based on user interaction              │
│  ├── Data can be fetched close to the database                 │
│  ├── Response includes large payload (keep off client bundle)  │
│  └── Security-sensitive data (API keys, tokens)                │
│                                                                  │
│  Use Client Fetching when:                                      │
│  ├── Data changes based on user interaction                    │
│  ├── Real-time updates needed                                  │
│  ├── Optimistic updates required                               │
│  ├── User-specific data that varies per request                │
│  └── Polling or subscription patterns                          │
│                                                                  │
│  Hybrid patterns:                                               │
│  ├── Server: Initial data, Client: Updates                     │
│  ├── Server: List, Client: Individual items on demand          │
│  └── Server: Static content, Client: Personalization           │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Implementation Patterns

// Pattern 1: Server fetches, client hydrates and updates

// Server Component
async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id);

  return (
    <>
      <ProductInfo product={product} />
      {/* Client component for real-time inventory */}
      <InventoryStatus
        productId={params.id}
        initialStock={product.stock}
      />
    </>
  );
}

// Client Component
'use client';
function InventoryStatus({
  productId,
  initialStock,
}: {
  productId: string;
  initialStock: number;
}) {
  const { data: inventory } = useQuery({
    queryKey: ['inventory', productId],
    queryFn: () => fetchInventory(productId),
    initialData: { stock: initialStock, updatedAt: Date.now() },
    refetchInterval: 30000, // Poll every 30s
  });

  return <StockBadge stock={inventory.stock} />;
}

// Pattern 2: Server prefetches, client consumes via cache

// Server Component with prefetch
async function ProductListPage({ params }: { params: { category: string } }) {
  const queryClient = new QueryClient();

  // Prefetch into query client
  await queryClient.prefetchQuery({
    queryKey: ['products', params.category],
    queryFn: () => fetchProducts(params.category),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProductList category={params.category} />
    </HydrationBoundary>
  );
}

// Client Component - data already in cache
'use client';
function ProductList({ category }: { category: string }) {
  const { data: products } = useQuery({
    queryKey: ['products', category],
    queryFn: () => fetchProducts(category),
    // Data is immediately available from hydration
  });

  return products.map((p) => <ProductCard key={p.id} product={p} />);
}

// Pattern 3: Server streams, client progressively renders

// Server Component with streaming
async function DashboardPage() {
  return (
    <>
      {/* Critical data - blocks render */}
      <DashboardHeader />

      {/* Streamed sections */}
      <Suspense fallback={<MetricsSkeleton />}>
        <DashboardMetrics />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <DashboardCharts />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <RecentActivityTable />
      </Suspense>
    </>
  );
}

// Each section is a Server Component that can fetch independently
async function DashboardMetrics() {
  const metrics = await fetchMetrics(); // Slow query
  return <MetricsGrid metrics={metrics} />;
}

Waterfall Avoidance

Identifying Waterfalls

┌─────────────────────────────────────────────────────────────────┐
│                    Request Waterfall Example                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Component Tree:                                                │
│                                                                  │
│  ProductPage                                                    │
│    └── useQuery('product', id)  ──────────────┐                │
│                                                │ 200ms          │
│        ProductDetails                          ▼                │
│          └── useQuery('inventory', id) ───────┐                │
│                                                │ 150ms          │
│              InventoryWarehouse                ▼                │
│                └── useQuery('warehouse', wId) ┐                │
│                                                │ 100ms          │
│                                                ▼                │
│  Total: 450ms sequential                                       │
│                                                                  │
│  If parallelized: max(200, 150, 100) = 200ms                   │
│  Savings: 55%                                                   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Solution 1: Parallel Queries

// Bad: Sequential (waterfall)
function ProductDetails({ productId }: { productId: string }) {
  const { data: product } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProduct(productId),
  });

  // This waits for product to be available
  const { data: inventory } = useQuery({
    queryKey: ['inventory', productId],
    queryFn: () => fetchInventory(productId),
    enabled: !!product, // Causes waterfall!
  });

  // This waits for inventory
  const { data: warehouse } = useQuery({
    queryKey: ['warehouse', inventory?.warehouseId],
    queryFn: () => fetchWarehouse(inventory!.warehouseId),
    enabled: !!inventory?.warehouseId, // Another waterfall!
  });

  return <div>{/* ... */}</div>;
}

// Good: Parallel queries
function ProductDetails({ productId }: { productId: string }) {
  // All queries start immediately
  const queries = useQueries({
    queries: [
      {
        queryKey: ['product', productId],
        queryFn: () => fetchProduct(productId),
      },
      {
        queryKey: ['inventory', productId],
        queryFn: () => fetchInventory(productId),
      },
      // For dependent queries, prefetch the dependency graph
    ],
  });

  const [productQuery, inventoryQuery] = queries;

  return <div>{/* ... */}</div>;
}

// Best: Server-side aggregation
async function ProductDetailsPage({ params }: { params: { id: string } }) {
  // Server fetches everything in parallel
  const [product, inventory, warehouse] = await Promise.all([
    fetchProduct(params.id),
    fetchInventory(params.id),
    fetchWarehouse(params.id), // Backend knows the warehouseId
  ]);

  return <ProductDetails product={product} inventory={inventory} warehouse={warehouse} />;
}

Solution 2: Data Loader Pattern

// Define data requirements at route level

// route-loaders.ts
export const productPageLoader = {
  // All data needed for product page
  queries: (params: { productId: string }) => [
    {
      queryKey: ['product', params.productId],
      queryFn: () => fetchProduct(params.productId),
    },
    {
      queryKey: ['inventory', params.productId],
      queryFn: () => fetchInventory(params.productId),
    },
    {
      queryKey: ['reviews', params.productId],
      queryFn: () => fetchReviews(params.productId),
    },
    {
      queryKey: ['recommendations', params.productId],
      queryFn: () => fetchRecommendations(params.productId),
    },
  ],

  // Prefetch on hover/focus for instant navigation
  prefetch: async (queryClient: QueryClient, params: { productId: string }) => {
    await Promise.all(
      productPageLoader.queries(params).map((query) =>
        queryClient.prefetchQuery(query)
      )
    );
  },
};

// Link with prefetch
function ProductLink({ productId, children }: { productId: string; children: React.ReactNode }) {
  const queryClient = useQueryClient();

  const prefetch = () => {
    productPageLoader.prefetch(queryClient, { productId });
  };

  return (
    <Link
      href={`/products/${productId}`}
      onMouseEnter={prefetch}
      onFocus={prefetch}
    >
      {children}
    </Link>
  );
}

// Page uses pre-loaded data
function ProductPage({ params }: { params: { productId: string } }) {
  // Data is likely already in cache from prefetch
  const queries = useQueries({
    queries: productPageLoader.queries(params),
  });

  // All queries run in parallel if not cached
  const isLoading = queries.some((q) => q.isLoading);

  if (isLoading) return <ProductPageSkeleton />;

  return <ProductContent queries={queries} />;
}

Solution 3: GraphQL / Backend Aggregation

// Let the backend handle the dependency graph

// Single request, server resolves dependencies
const PRODUCT_QUERY = gql`
  query ProductPage($id: ID!) {
    product(id: $id) {
      id
      name
      price
      inventory {
        stock
        warehouse {
          name
          location
        }
      }
      reviews(limit: 10) {
        id
        rating
        author {
          name
          avatar
        }
      }
      recommendations(limit: 5) {
        id
        name
        image
      }
    }
  }
`;

function ProductPage({ productId }: { productId: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['product-page', productId],
    queryFn: () => graphqlClient.request(PRODUCT_QUERY, { id: productId }),
  });

  // Single request, no waterfalls
  // Server handles all the dependency resolution
  return <ProductContent data={data} />;
}

// REST equivalent: aggregation endpoint
async function fetchProductPage(productId: string): Promise<ProductPageData> {
  const response = await fetch(`/api/pages/product/${productId}`);
  return response.json();
}

// Backend aggregates all data
// GET /api/pages/product/:id
app.get('/api/pages/product/:id', async (req, res) => {
  const productId = req.params.id;

  // Parallel fetch on server
  const [product, inventory, reviews, recommendations] = await Promise.all([
    db.products.findUnique({ where: { id: productId } }),
    inventoryService.getStock(productId),
    reviewsService.getReviews(productId, { limit: 10 }),
    recommendationsService.getRecommendations(productId, { limit: 5 }),
  ]);

  res.json({ product, inventory, reviews, recommendations });
});

Data Dependency Graphs

Modeling Dependencies

// Define explicit data dependencies

interface DataDependency {
  id: string;
  queryKey: unknown[];
  queryFn: () => Promise<unknown>;
  dependsOn: string[]; // IDs of dependencies
  priority: 'critical' | 'high' | 'low';
}

const productPageDependencies: DataDependency[] = [
  {
    id: 'product',
    queryKey: ['product', '{productId}'],
    queryFn: () => fetchProduct(productId),
    dependsOn: [],
    priority: 'critical',
  },
  {
    id: 'inventory',
    queryKey: ['inventory', '{productId}'],
    queryFn: () => fetchInventory(productId),
    dependsOn: [], // Can fetch in parallel with product
    priority: 'critical',
  },
  {
    id: 'warehouse',
    queryKey: ['warehouse', '{warehouseId}'],
    queryFn: () => fetchWarehouse(inventoryData.warehouseId),
    dependsOn: ['inventory'], // Needs inventory first
    priority: 'high',
  },
  {
    id: 'reviews',
    queryKey: ['reviews', '{productId}'],
    queryFn: () => fetchReviews(productId),
    dependsOn: [],
    priority: 'low',
  },
  {
    id: 'review-authors',
    queryKey: ['users', '{authorIds}'],
    queryFn: () => fetchUsers(reviewsData.map(r => r.authorId)),
    dependsOn: ['reviews'], // Needs reviews first
    priority: 'low',
  },
];

// Topological sort for optimal execution order
function getExecutionOrder(dependencies: DataDependency[]): DataDependency[][] {
  const levels: DataDependency[][] = [];
  const remaining = new Set(dependencies);
  const resolved = new Set<string>();

  while (remaining.size > 0) {
    const level: DataDependency[] = [];

    for (const dep of remaining) {
      const canResolve = dep.dependsOn.every((d) => resolved.has(d));
      if (canResolve) {
        level.push(dep);
      }
    }

    if (level.length === 0) {
      throw new Error('Circular dependency detected');
    }

    levels.push(level);
    level.forEach((dep) => {
      remaining.delete(dep);
      resolved.add(dep.id);
    });
  }

  return levels;
}

// Execute with optimal parallelization
async function executeDependencies(
  dependencies: DataDependency[]
): Promise<Map<string, unknown>> {
  const results = new Map<string, unknown>();
  const levels = getExecutionOrder(dependencies);

  for (const level of levels) {
    // Execute all queries in this level in parallel
    const levelResults = await Promise.all(
      level.map(async (dep) => {
        const data = await dep.queryFn();
        return { id: dep.id, data };
      })
    );

    levelResults.forEach(({ id, data }) => results.set(id, data));
  }

  return results;
}

// Visualization
/*
Level 0 (parallel): product, inventory, reviews
Level 1 (parallel): warehouse (needs inventory), review-authors (needs reviews)

Timeline:
|-- product ---|
|-- inventory -|-- warehouse ---|
|-- reviews ----|-- review-authors ---|

Total time: max(product, inventory + warehouse, reviews + review-authors)
*/

Avoiding Circular Dependencies

// Detect and prevent circular dependencies

class DependencyGraph {
  private adjacencyList: Map<string, Set<string>> = new Map();

  addDependency(from: string, to: string): void {
    if (this.wouldCreateCycle(from, to)) {
      throw new Error(`Adding ${from} -> ${to} would create a cycle`);
    }

    if (!this.adjacencyList.has(from)) {
      this.adjacencyList.set(from, new Set());
    }
    this.adjacencyList.get(from)!.add(to);
  }

  private wouldCreateCycle(from: string, to: string): boolean {
    // If 'to' can reach 'from', adding from -> to creates cycle
    return this.canReach(to, from);
  }

  private canReach(start: string, target: string): boolean {
    const visited = new Set<string>();
    const queue = [start];

    while (queue.length > 0) {
      const current = queue.shift()!;
      if (current === target) return true;

      if (visited.has(current)) continue;
      visited.add(current);

      const neighbors = this.adjacencyList.get(current) ?? new Set();
      queue.push(...neighbors);
    }

    return false;
  }

  getTopologicalOrder(): string[] {
    const inDegree = new Map<string, number>();
    const nodes = new Set<string>();

    // Initialize
    for (const [node, deps] of this.adjacencyList) {
      nodes.add(node);
      deps.forEach((d) => nodes.add(d));
    }

    for (const node of nodes) {
      inDegree.set(node, 0);
    }

    for (const [, deps] of this.adjacencyList) {
      deps.forEach((dep) => {
        inDegree.set(dep, (inDegree.get(dep) ?? 0) + 1);
      });
    }

    // Kahn's algorithm
    const queue = [...nodes].filter((n) => inDegree.get(n) === 0);
    const order: string[] = [];

    while (queue.length > 0) {
      const node = queue.shift()!;
      order.push(node);

      for (const dep of this.adjacencyList.get(node) ?? []) {
        inDegree.set(dep, inDegree.get(dep)! - 1);
        if (inDegree.get(dep) === 0) {
          queue.push(dep);
        }
      }
    }

    if (order.length !== nodes.size) {
      throw new Error('Cycle detected in dependency graph');
    }

    return order;
  }
}

Prefetching Strategies

// Strategic prefetching for instant navigation

// 1. Route-based prefetching
const routePrefetchConfig: Record<string, (params: unknown) => QueryConfig[]> = {
  '/products': () => [
    { queryKey: ['categories'], queryFn: fetchCategories },
    { queryKey: ['featured-products'], queryFn: fetchFeaturedProducts },
  ],
  '/products/:id': ({ id }) => [
    { queryKey: ['product', id], queryFn: () => fetchProduct(id) },
    { queryKey: ['inventory', id], queryFn: () => fetchInventory(id) },
  ],
  '/cart': () => [
    { queryKey: ['cart'], queryFn: fetchCart },
    { queryKey: ['shipping-options'], queryFn: fetchShippingOptions },
  ],
};

// Prefetch on route change
function usePrefetchOnNavigation() {
  const router = useRouter();
  const queryClient = useQueryClient();
  const pathname = usePathname();

  useEffect(() => {
    // Match current route to config
    for (const [pattern, getQueries] of Object.entries(routePrefetchConfig)) {
      const match = matchPath(pattern, pathname);
      if (match) {
        const queries = getQueries(match.params);
        queries.forEach((query) => {
          queryClient.prefetchQuery(query);
        });
        break;
      }
    }
  }, [pathname, queryClient]);
}

// 2. Predictive prefetching
function usePredictivePrefetch() {
  const queryClient = useQueryClient();
  const [predictions, setPredictions] = useState<string[]>([]);

  // Track user behavior
  useEffect(() => {
    const handleClick = (e: MouseEvent) => {
      const target = e.target as HTMLElement;
      const link = target.closest('a');

      if (link) {
        // User might click nearby links too
        const nearbyLinks = findNearbyLinks(link);
        const topPredictions = nearbyLinks.slice(0, 3);

        topPredictions.forEach((href) => {
          const route = parseRoute(href);
          if (route && routePrefetchConfig[route.pattern]) {
            const queries = routePrefetchConfig[route.pattern](route.params);
            queries.forEach((query) => {
              queryClient.prefetchQuery({
                ...query,
                staleTime: 30000, // Prefetched data is fresh for 30s
              });
            });
          }
        });
      }
    };

    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [queryClient]);
}

// 3. Viewport-based prefetching
function PrefetchOnVisible({
  queries,
  children,
}: {
  queries: QueryConfig[];
  children: React.ReactNode;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const queryClient = useQueryClient();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          queries.forEach((query) => {
            queryClient.prefetchQuery(query);
          });
          observer.disconnect();
        }
      },
      { rootMargin: '100px' } // Prefetch when 100px from viewport
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    return () => observer.disconnect();
  }, [queries, queryClient]);

  return <div ref={ref}>{children}</div>;
}

Summary

Data fetching architecture decisions:

PatternUse WhenTrade-off
DecentralizedSmall apps, reusable componentsPotential N+1 queries
CentralizedKnown data needs, optimized queriesTight coupling
HybridLarge apps, varying requirementsComplexity

Key principles:

  1. Define ownership — Each piece of data has one source of truth
  2. Parallelize aggressively — Don't let component tree create waterfalls
  3. Server-fetch when possible — Reduce client bundle and latency
  4. Prefetch predictively — Anticipate user navigation
  5. Cache intelligently — Share data across components, dedupe requests
  6. Model dependencies explicitly — Understand your data graph

The goal: every navigation feels instant, every component has its data, and no request is made twice.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.