Back to Blog

Data Fetching Architecture in React Apps

React Query vs SWR vs server components vs Apollo — not a feature comparison but an architectural fit guide based on app complexity, team size, and data patterns


Beyond the Feature Matrix

Every "React Query vs SWR vs Apollo" article compares features. Cache invalidation options, bundle sizes, TypeScript support. But when you're architecting a real application, features don't matter—fit matters.

The question isn't "which library has more features?" It's "which approach matches my data patterns, team capabilities, and application complexity?" A feature-rich solution that doesn't fit your architecture will create more problems than it solves.

This guide will help you choose based on what actually matters: how your data flows, how your team works, and where your application is headed.


Understanding Data Fetching Patterns

Before choosing tools, understand your data:

┌─────────────────────────────────────────────────────────────────────────────┐
│                    DATA PATTERN DIMENSIONS                                   │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Dimension 1: Data Ownership                                                │
│  ────────────────────────────                                                │
│  Server-Owned          Shared              Client-Owned                     │
│  ────────────────────────────────────────────────────────────────           │
│  Data lives on server  Data synced         Data lives in                   │
│  Client is a cache     between both        browser only                    │
│  Examples:             Examples:           Examples:                        │
│  • User profiles       • Collaborative     • Form drafts                   │
│  • Product catalog       docs              • UI preferences                │
│  • Order history       • Real-time chat    • Undo history                  │
│                        • Multiplayer                                        │
│                                                                              │
│  Dimension 2: Update Frequency                                              │
│  ─────────────────────────────                                               │
│  Static               Periodic             Real-time                        │
│  ────────────────────────────────────────────────────────────────           │
│  Changes rarely       Changes              Changes                          │
│  Cache aggressively   occasionally         constantly                       │
│  Examples:            Examples:            Examples:                        │
│  • App config         • User data          • Live scores                   │
│  • Feature flags      • Feed content       • Stock prices                  │
│  • Static content     • Notifications      • Chat messages                 │
│                                                                              │
│  Dimension 3: Data Shape                                                    │
│  ───────────────────────                                                     │
│  Document             Normalized           Graph                            │
│  ────────────────────────────────────────────────────────────────           │
│  Self-contained       Entities with        Complex                          │
│  objects              relationships        relationships                    │
│  Examples:            Examples:            Examples:                        │
│  • Blog posts         • Users + Orders     • Social network                │
│  • Comments           • Products + Tags    • Knowledge base                │
│  • Settings           • Teams + Members    • Recommendation                │
│                                                                              │
│  Dimension 4: Consistency Requirements                                      │
│  ─────────────────────────────────────                                       │
│  Eventual             Read-your-writes     Strong                           │
│  ────────────────────────────────────────────────────────────────           │
│  Updates can lag      User sees own        All users see                   │
│  seconds/minutes      changes immediately  same data                       │
│  Examples:            Examples:            Examples:                        │
│  • Analytics          • Profile updates    • Banking                       │
│  • Recommendations    • Post creation      • Reservations                  │
│  • Social feeds       • Settings changes   • Inventory                     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Data Pattern Assessment

// Use this to assess your application's data patterns

interface DataPatternAssessment {
  // Primary data characteristics
  ownership: 'server' | 'shared' | 'client';
  updateFrequency: 'static' | 'periodic' | 'realtime';
  shape: 'document' | 'normalized' | 'graph';
  consistency: 'eventual' | 'read-your-writes' | 'strong';

  // Complexity factors
  numberOfEntities: number;           // How many different data types
  crossEntityRelationships: boolean;  // Do entities reference each other
  offlineRequirements: boolean;       // Must work offline
  optimisticUpdates: boolean;         // Need instant UI feedback

  // Scale factors
  dataVolumePerPage: 'low' | 'medium' | 'high';  // KB of data per page
  concurrentQueries: number;                      // Parallel requests typical
  cacheComplexity: 'simple' | 'moderate' | 'complex';

  // Team factors
  teamSize: number;
  graphQLExperience: boolean;
  backendControl: boolean;  // Can you change the API?
}

// Example assessments
const simpleApp: DataPatternAssessment = {
  ownership: 'server',
  updateFrequency: 'periodic',
  shape: 'document',
  consistency: 'read-your-writes',
  numberOfEntities: 5,
  crossEntityRelationships: false,
  offlineRequirements: false,
  optimisticUpdates: false,
  dataVolumePerPage: 'low',
  concurrentQueries: 2,
  cacheComplexity: 'simple',
  teamSize: 3,
  graphQLExperience: false,
  backendControl: true,
};
// Recommendation: SWR or simple React Query

const complexDashboard: DataPatternAssessment = {
  ownership: 'server',
  updateFrequency: 'periodic',
  shape: 'normalized',
  consistency: 'read-your-writes',
  numberOfEntities: 25,
  crossEntityRelationships: true,
  offlineRequirements: false,
  optimisticUpdates: true,
  dataVolumePerPage: 'high',
  concurrentQueries: 10,
  cacheComplexity: 'complex',
  teamSize: 8,
  graphQLExperience: false,
  backendControl: true,
};
// Recommendation: React Query with careful cache design

const socialPlatform: DataPatternAssessment = {
  ownership: 'shared',
  updateFrequency: 'realtime',
  shape: 'graph',
  consistency: 'read-your-writes',
  numberOfEntities: 50,
  crossEntityRelationships: true,
  offlineRequirements: true,
  optimisticUpdates: true,
  dataVolumePerPage: 'high',
  concurrentQueries: 15,
  cacheComplexity: 'complex',
  teamSize: 20,
  graphQLExperience: true,
  backendControl: true,
};
// Recommendation: Apollo Client or Relay

The Options Landscape

┌─────────────────────────────────────────────────────────────────────────────┐
│                    DATA FETCHING OPTIONS                                     │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│              Simplicity ◄────────────────────────────► Power                │
│                                                                              │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐        │
│  │   fetch +   │  │    SWR      │  │ React Query │  │   Apollo    │        │
│  │   useState  │  │             │  │             │  │   Client    │        │
│  └─────────────┘  └─────────────┘  └─────────────┘  └─────────────┘        │
│                                                                              │
│  │               │               │               │               │          │
│  │  No learning  │  Minimal      │  Moderate     │  Significant  │          │
│  │  curve        │  learning     │  learning     │  learning     │          │
│  │               │  curve        │  curve        │  curve        │          │
│  │               │               │               │               │          │
│  │  Manual       │  Auto cache   │  Powerful     │  Normalized   │          │
│  │  everything   │  & revalidate │  cache mgmt   │  cache        │          │
│  │               │               │               │               │          │
│  │  No cache     │  URL-based    │  Query-key    │  Entity-based │          │
│  │               │  cache        │  cache        │  cache        │          │
│  │               │               │               │               │          │
│  │  REST only    │  REST focused │  Any protocol │  GraphQL      │          │
│  │               │               │               │  native       │          │
│                                                                              │
│  ─────────────────────────────────────────────────────────────────────      │
│                                                                              │
│  ┌─────────────┐  ┌─────────────┐                                           │
│  │   Server    │  │   Relay     │                                           │
│  │ Components  │  │             │                                           │
│  └─────────────┘  └─────────────┘                                           │
│                                                                              │
│  │               │               │                                           │
│  │  Next.js/RSC  │  Facebook's   │                                           │
│  │  only         │  GraphQL lib  │                                           │
│  │               │               │                                           │
│  │  No client    │  Compiler-    │                                           │
│  │  state        │  based        │                                           │
│  │               │               │                                           │
│  │  Server       │  Maximum      │                                           │
│  │  rendering    │  type safety  │                                           │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Server Components: The New Baseline

Before choosing a client-side solution, ask: do you even need one?

When Server Components Are Enough

// Server Components handle many data fetching patterns elegantly

// app/products/page.tsx
async function ProductsPage() {
  // Direct database access - no API layer needed
  const products = await prisma.product.findMany({
    where: { active: true },
    include: { category: true },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <div className="grid grid-cols-3 gap-4">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// Parallel data fetching - automatic
async function DashboardPage() {
  // These run in parallel automatically
  const [stats, recentOrders, topProducts] = await Promise.all([
    getStats(),
    getRecentOrders(),
    getTopProducts(),
  ]);

  return (
    <div>
      <StatsOverview stats={stats} />
      <RecentOrders orders={recentOrders} />
      <TopProducts products={topProducts} />
    </div>
  );
}

// Nested data fetching with Suspense
async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId);

  return (
    <div>
      <UserHeader user={user} />
      {/* This fetches in parallel, streams when ready */}
      <Suspense fallback={<OrdersSkeleton />}>
        <UserOrders userId={userId} />
      </Suspense>
      <Suspense fallback={<ReviewsSkeleton />}>
        <UserReviews userId={userId} />
      </Suspense>
    </div>
  );
}

Server Components Limitations

┌─────────────────────────────────────────────────────────────────────────────┐
│                    SERVER COMPONENTS: WHEN THEY'RE NOT ENOUGH               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Need Client Data Fetching When:                                            │
│                                                                              │
│  ❌ User interactions trigger data changes                                  │
│     • Pagination, filtering, sorting by user                               │
│     • Search with debouncing                                                │
│     • Infinite scroll                                                       │
│                                                                              │
│  ❌ Data needs to update without navigation                                 │
│     • Polling for updates                                                   │
│     • Real-time subscriptions                                               │
│     • Refetch after mutations                                               │
│                                                                              │
│  ❌ Optimistic updates required                                             │
│     • Like/unlike buttons                                                   │
│     • Add to cart                                                           │
│     • Any action where waiting feels slow                                   │
│                                                                              │
│  ❌ Complex cache management                                                 │
│     • Same data used in multiple places                                     │
│     • Partial updates to cached data                                        │
│     • Background refetching                                                 │
│                                                                              │
│  ❌ Offline support                                                          │
│     • Data must persist across page loads                                   │
│     • Mutations queue when offline                                          │
│                                                                              │
│  Server Components + Client Fetching library is often the answer           │
│  Initial data: Server Components                                            │
│  Interactive updates: React Query/SWR                                       │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Hybrid Pattern: Server Components + Client Fetching

// The best of both worlds

// app/products/page.tsx (Server Component)
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { ProductList } from './product-list';

export default async function ProductsPage() {
  const queryClient = getQueryClient();

  // Prefetch on server
  await queryClient.prefetchQuery({
    queryKey: ['products', { page: 1 }],
    queryFn: () => fetchProducts({ page: 1 }),
  });

  return (
    // Pass server state to client
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProductList />
    </HydrationBoundary>
  );
}

// app/products/product-list.tsx (Client Component)
'use client';

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

export function ProductList() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');

  // Hydrates from server, then handles client interactions
  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ['products', { page, search }],
    queryFn: () => fetchProducts({ page, search }),
    placeholderData: keepPreviousData,
  });

  return (
    <div>
      <SearchInput value={search} onChange={setSearch} />
      <ProductGrid products={data?.products} loading={isLoading} />
      <Pagination
        page={page}
        totalPages={data?.totalPages}
        onChange={setPage}
        disabled={isPlaceholderData}
      />
    </div>
  );
}

SWR: The Simple Choice

SWR is perfect when you want caching without complexity.

SWR Sweet Spot

┌─────────────────────────────────────────────────────────────────────────────┐
│                    SWR IDEAL USE CASES                                       │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Perfect For:                                                                │
│  ─────────────                                                               │
│  • Small to medium applications                                             │
│  • Teams new to data fetching libraries                                     │
│  • Simple cache requirements (URL-based)                                    │
│  • Read-heavy applications (few mutations)                                  │
│  • Vercel/Next.js ecosystem alignment                                       │
│                                                                              │
│  Key Strengths:                                                              │
│  ───────────────                                                             │
│  • Minimal API surface - learn in an hour                                   │
│  • Stale-while-revalidate out of the box                                   │
│  • Automatic revalidation on focus/reconnect                               │
│  • Small bundle (~4KB)                                                      │
│  • Simple mental model                                                      │
│                                                                              │
│  Limitations:                                                                │
│  ─────────────                                                               │
│  • Mutations are basic (no optimistic by default)                          │
│  • No normalized cache                                                      │
│  • Limited query invalidation patterns                                     │
│  • DevTools not as powerful                                                │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

SWR Patterns

// lib/swr/hooks.ts
import useSWR, { mutate } from 'swr';
import useSWRMutation from 'swr/mutation';

// Standard fetcher
const fetcher = (url: string) => fetch(url).then(r => r.json());

// Basic usage - dead simple
export function useUser(userId: string) {
  return useSWR(`/api/users/${userId}`, fetcher);
}

// With options
export function useProducts(filters: ProductFilters) {
  const params = new URLSearchParams(filters as Record<string, string>);

  return useSWR(
    `/api/products?${params}`,
    fetcher,
    {
      revalidateOnFocus: false,       // Don't refetch on tab focus
      dedupingInterval: 60000,        // Dedupe requests within 1 minute
      keepPreviousData: true,         // Keep showing old data while fetching
    }
  );
}

// Conditional fetching
export function useUserOrders(userId: string | null) {
  // Pass null key to disable fetching
  return useSWR(
    userId ? `/api/users/${userId}/orders` : null,
    fetcher
  );
}

// Mutations with useSWRMutation
export function useCreateOrder() {
  return useSWRMutation(
    '/api/orders',
    async (url: string, { arg }: { arg: CreateOrderInput }) => {
      const response = await fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(arg),
      });

      if (!response.ok) {
        throw new Error('Failed to create order');
      }

      return response.json();
    },
    {
      onSuccess: () => {
        // Revalidate related data
        mutate('/api/orders');
        mutate(key => typeof key === 'string' && key.startsWith('/api/users/'));
      },
    }
  );
}

// SWR's simplicity means manual optimistic updates
export function useLikePost() {
  return useSWRMutation(
    '/api/like',
    async (url: string, { arg }: { arg: { postId: string } }) => {
      const response = await fetch(url, {
        method: 'POST',
        body: JSON.stringify(arg),
      });
      return response.json();
    },
    {
      // Optimistic update requires more manual work
      onMutate: async ({ postId }) => {
        // Update local cache optimistically
        mutate(
          `/api/posts/${postId}`,
          (current: Post) => ({
            ...current,
            likes: current.likes + 1,
            likedByMe: true,
          }),
          { revalidate: false }
        );
      },
      onError: (error, { postId }) => {
        // Rollback on error
        mutate(`/api/posts/${postId}`);
      },
    }
  );
}

When to Outgrow SWR

// Signs you might need more than SWR:

// 1. Complex cache invalidation patterns
// SWR: Manual, URL-based
mutate('/api/orders');
mutate(key => typeof key === 'string' && key.includes('/orders'));
// Can get messy with many related queries

// 2. Optimistic updates become boilerplate
// Every mutation needs manual optimistic/rollback logic
// React Query's mutation context handles this better

// 3. You need query cancellation
// SWR doesn't have built-in query cancellation
// Long-running queries can cause race conditions

// 4. Parallel dependent queries
// "Fetch user, then fetch their permissions, then fetch their team"
// useQueries in React Query handles this elegantly

// 5. Offline support
// SWR's offline story is limited
// React Query has built-in offline mutation persistence

React Query: The Pragmatic Powerhouse

React Query (TanStack Query) hits the sweet spot for most complex applications.

React Query Sweet Spot

┌─────────────────────────────────────────────────────────────────────────────┐
│                    REACT QUERY IDEAL USE CASES                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Perfect For:                                                                │
│  ─────────────                                                               │
│  • Medium to large applications                                             │
│  • Teams wanting structure without GraphQL                                  │
│  • REST/RPC APIs (works with anything)                                      │
│  • Applications with complex mutations                                      │
│  • When you need fine-grained cache control                                │
│                                                                              │
│  Key Strengths:                                                              │
│  ───────────────                                                             │
│  • Powerful cache invalidation (query keys)                                │
│  • Built-in optimistic updates                                             │
│  • Excellent mutation handling                                             │
│  • Great DevTools                                                          │
│  • TypeScript-first                                                        │
│  • Offline mutation persistence                                            │
│  • Works with any async function                                           │
│                                                                              │
│  Limitations:                                                                │
│  ─────────────                                                               │
│  • More concepts to learn                                                  │
│  • Cache is not normalized (by design)                                     │
│  • Larger bundle (~13KB)                                                   │
│  • Can over-engineer simple apps                                           │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

React Query Architecture Patterns

// lib/queries/query-keys.ts
// Structured query keys for consistent cache management

export const queryKeys = {
  // User domain
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (filters: UserFilters) =>
      [...queryKeys.users.lists(), filters] as const,
    details: () => [...queryKeys.users.all, 'detail'] as const,
    detail: (id: string) => [...queryKeys.users.details(), id] as const,
  },

  // Order domain
  orders: {
    all: ['orders'] as const,
    lists: () => [...queryKeys.orders.all, 'list'] as const,
    list: (filters: OrderFilters) =>
      [...queryKeys.orders.lists(), filters] as const,
    details: () => [...queryKeys.orders.all, 'detail'] as const,
    detail: (id: string) => [...queryKeys.orders.details(), id] as const,
    byUser: (userId: string) =>
      [...queryKeys.orders.all, 'user', userId] as const,
  },

  // Product domain
  products: {
    all: ['products'] as const,
    lists: () => [...queryKeys.products.all, 'list'] as const,
    list: (filters: ProductFilters) =>
      [...queryKeys.products.lists(), filters] as const,
    detail: (id: string) => [...queryKeys.products.all, 'detail', id] as const,
    inventory: (id: string) =>
      [...queryKeys.products.all, 'inventory', id] as const,
  },
};

// Usage: Surgical cache invalidation
queryClient.invalidateQueries({
  queryKey: queryKeys.orders.byUser(userId)
});

// Invalidate all orders
queryClient.invalidateQueries({
  queryKey: queryKeys.orders.all
});
// lib/queries/hooks/use-orders.ts
// Feature-complete query hook

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '../query-keys';

interface UseOrdersOptions {
  userId?: string;
  status?: OrderStatus;
  page?: number;
  enabled?: boolean;
}

export function useOrders(options: UseOrdersOptions = {}) {
  const { userId, status, page = 1, enabled = true } = options;

  return useQuery({
    queryKey: queryKeys.orders.list({ userId, status, page }),
    queryFn: () => fetchOrders({ userId, status, page }),
    enabled,
    staleTime: 5 * 60 * 1000,       // Fresh for 5 minutes
    gcTime: 30 * 60 * 1000,         // Keep in cache for 30 minutes
    placeholderData: keepPreviousData, // Keep showing while paginating
  });
}

export function useOrder(orderId: string) {
  return useQuery({
    queryKey: queryKeys.orders.detail(orderId),
    queryFn: () => fetchOrder(orderId),
    staleTime: 60 * 1000,
  });
}

export function useCreateOrder() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createOrder,

    // Optimistic update
    onMutate: async (newOrder) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({
        queryKey: queryKeys.orders.lists()
      });

      // Snapshot previous value
      const previousOrders = queryClient.getQueryData(
        queryKeys.orders.list({})
      );

      // Optimistically update
      queryClient.setQueryData(
        queryKeys.orders.list({}),
        (old: OrdersResponse) => ({
          ...old,
          orders: [{ ...newOrder, id: 'temp', status: 'pending' }, ...old.orders],
        })
      );

      return { previousOrders };
    },

    onError: (error, variables, context) => {
      // Rollback on error
      if (context?.previousOrders) {
        queryClient.setQueryData(
          queryKeys.orders.list({}),
          context.previousOrders
        );
      }
    },

    onSettled: () => {
      // Refetch to ensure consistency
      queryClient.invalidateQueries({
        queryKey: queryKeys.orders.lists()
      });
    },
  });
}

export function useUpdateOrderStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ orderId, status }: { orderId: string; status: OrderStatus }) =>
      updateOrderStatus(orderId, status),

    onMutate: async ({ orderId, status }) => {
      await queryClient.cancelQueries({
        queryKey: queryKeys.orders.detail(orderId)
      });

      const previousOrder = queryClient.getQueryData<Order>(
        queryKeys.orders.detail(orderId)
      );

      // Update the specific order
      queryClient.setQueryData(
        queryKeys.orders.detail(orderId),
        (old: Order) => ({ ...old, status })
      );

      // Also update in lists
      queryClient.setQueriesData(
        { queryKey: queryKeys.orders.lists() },
        (old: OrdersResponse | undefined) => {
          if (!old) return old;
          return {
            ...old,
            orders: old.orders.map(order =>
              order.id === orderId ? { ...order, status } : order
            ),
          };
        }
      );

      return { previousOrder };
    },

    onError: (error, { orderId }, context) => {
      if (context?.previousOrder) {
        queryClient.setQueryData(
          queryKeys.orders.detail(orderId),
          context.previousOrder
        );
      }
      queryClient.invalidateQueries({
        queryKey: queryKeys.orders.lists()
      });
    },
  });
}

React Query + Server Components

// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';
import { cache } from 'react';

// Server-side query client (one per request)
export const getQueryClient = cache(() => new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
    },
  },
}));

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  // Client-side query client (one per app lifecycle)
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        refetchOnWindowFocus: false,
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Apollo Client: The GraphQL Native

Apollo makes sense when GraphQL is your foundation, not an afterthought.

Apollo Sweet Spot

┌─────────────────────────────────────────────────────────────────────────────┐
│                    APOLLO CLIENT IDEAL USE CASES                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Perfect For:                                                                │
│  ─────────────                                                               │
│  • GraphQL-first architectures                                              │
│  • Complex data relationships (graph-shaped data)                           │
│  • Large teams where schema is the contract                                 │
│  • Applications needing normalized cache                                    │
│  • Real-time subscriptions as core feature                                 │
│                                                                              │
│  Key Strengths:                                                              │
│  ───────────────                                                             │
│  • Normalized cache - update once, reflects everywhere                     │
│  • GraphQL-native (queries, mutations, subscriptions)                      │
│  • Type generation from schema                                             │
│  • Mature ecosystem                                                        │
│  • Local state management built-in                                         │
│  • Sophisticated cache policies                                            │
│                                                                              │
│  Limitations:                                                                │
│  ─────────────                                                               │
│  • Requires GraphQL (obviously)                                            │
│  • Large bundle (~35KB+)                                                    │
│  • Steep learning curve                                                    │
│  • Cache can be hard to debug                                              │
│  • Overkill for simple GraphQL usage                                       │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

When Apollo's Normalized Cache Shines

// The killer feature: normalized cache

// Consider this data structure:
// User appears in:
// - User profile page
// - Comment authors
// - Post authors
// - Team member lists
// - Activity feed

// With React Query/SWR (document cache):
// - Update user name
// - Need to invalidate: profile, comments, posts, teams, activity
// - Or user sees inconsistent data

// With Apollo (normalized cache):
// - Update user name
// - All places automatically update
// - Data is stored by ID, not by query

// Apollo cache structure:
const apolloCache = {
  'User:123': {
    id: '123',
    name: 'John Doe',
    email: 'john@example.com',
  },
  'Post:456': {
    id: '456',
    title: 'Hello World',
    author: { __ref: 'User:123' },  // Reference, not duplicate
  },
  'Comment:789': {
    id: '789',
    body: 'Great post!',
    author: { __ref: 'User:123' },  // Same reference
  },
  ROOT_QUERY: {
    'user(id:"123")': { __ref: 'User:123' },
    'post(id:"456")': { __ref: 'Post:456' },
  },
};

// Update user name once:
cache.modify({
  id: cache.identify(user),
  fields: {
    name: () => 'Jane Doe',
  },
});
// All references automatically reflect the change

Apollo Architecture Patterns

// lib/apollo/client.ts
import {
  ApolloClient,
  InMemoryCache,
  HttpLink,
  split,
} from '@apollo/client';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';

// HTTP link for queries and mutations
const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_URL,
  credentials: 'include',
});

// WebSocket link for subscriptions
const wsLink = typeof window !== 'undefined'
  ? new GraphQLWsLink(
      createClient({
        url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL!,
        connectionParams: () => ({
          authorization: getAuthToken(),
        }),
      })
    )
  : null;

// Split based on operation type
const splitLink = wsLink
  ? split(
      ({ query }) => {
        const definition = getMainDefinition(query);
        return (
          definition.kind === 'OperationDefinition' &&
          definition.operation === 'subscription'
        );
      },
      wsLink,
      httpLink
    )
  : httpLink;

// Type policies for cache behavior
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // Pagination with cursor
        posts: {
          keyArgs: ['filter', 'sortBy'],
          merge(existing = { edges: [] }, incoming, { args }) {
            if (!args?.after) {
              return incoming; // First page, replace
            }
            return {
              ...incoming,
              edges: [...existing.edges, ...incoming.edges],
            };
          },
        },
      },
    },

    User: {
      fields: {
        // Computed field
        fullName: {
          read(_, { readField }) {
            const firstName = readField<string>('firstName');
            const lastName = readField<string>('lastName');
            return `${firstName} ${lastName}`;
          },
        },
      },
    },

    Post: {
      fields: {
        // Optimistic response handling
        likeCount: {
          merge(existing = 0, incoming) {
            return incoming;
          },
        },
      },
    },
  },
});

export const apolloClient = new ApolloClient({
  link: splitLink,
  cache,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
    },
  },
});
// hooks/use-posts.ts
import { gql, useQuery, useMutation, useSubscription } from '@apollo/client';

const GET_POSTS = gql`
  query GetPosts($filter: PostFilter, $after: String, $first: Int) {
    posts(filter: $filter, after: $after, first: $first) {
      edges {
        cursor
        node {
          id
          title
          body
          author {
            id
            name
            avatarUrl
          }
          likeCount
          likedByMe
          createdAt
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

const LIKE_POST = gql`
  mutation LikePost($postId: ID!) {
    likePost(postId: $postId) {
      id
      likeCount
      likedByMe
    }
  }
`;

const POST_UPDATED = gql`
  subscription OnPostUpdated($postId: ID!) {
    postUpdated(postId: $postId) {
      id
      title
      body
      likeCount
    }
  }
`;

export function usePosts(filter?: PostFilter) {
  const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
    variables: { filter, first: 20 },
    notifyOnNetworkStatusChange: true,
  });

  const loadMore = () => {
    if (data?.posts.pageInfo.hasNextPage) {
      fetchMore({
        variables: {
          after: data.posts.pageInfo.endCursor,
        },
      });
    }
  };

  return {
    posts: data?.posts.edges.map(e => e.node) ?? [],
    loading,
    error,
    hasMore: data?.posts.pageInfo.hasNextPage ?? false,
    loadMore,
  };
}

export function useLikePost() {
  const [likePost, { loading }] = useMutation(LIKE_POST, {
    // Optimistic response - instant UI update
    optimisticResponse: ({ postId }) => ({
      likePost: {
        __typename: 'Post',
        id: postId,
        likeCount: 0, // Will be corrected by cache.modify
        likedByMe: true,
      },
    }),

    // Update cache before server responds
    update: (cache, { data }, { variables }) => {
      cache.modify({
        id: cache.identify({ __typename: 'Post', id: variables?.postId }),
        fields: {
          likeCount: (existing) => existing + 1,
          likedByMe: () => true,
        },
      });
    },
  });

  return { likePost: (postId: string) => likePost({ variables: { postId } }), loading };
}

export function usePostUpdates(postId: string) {
  const { data } = useSubscription(POST_UPDATED, {
    variables: { postId },
  });

  // Subscription data automatically updates cache
  return data?.postUpdated;
}

Decision Framework

The Selection Matrix

┌─────────────────────────────────────────────────────────────────────────────┐
│                    DATA FETCHING DECISION MATRIX                             │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Start Here: What's your API?                                               │
│  ─────────────────────────────                                               │
│                                                                              │
│  REST/RPC API                        GraphQL API                            │
│       │                                   │                                  │
│       ▼                                   ▼                                  │
│  ┌─────────────┐                    ┌─────────────────┐                     │
│  │ How complex │                    │ Do you need the │                     │
│  │ is caching? │                    │ normalized      │                     │
│  └──────┬──────┘                    │ cache?          │                     │
│         │                            └────────┬────────┘                     │
│    ┌────┼────┐                           ┌────┼────┐                        │
│    ▼    ▼    ▼                           ▼         ▼                        │
│  Simple Med Complex                    Yes         No                       │
│    │    │    │                          │          │                        │
│    ▼    ▼    ▼                          ▼          ▼                        │
│  SWR  RQ   RQ+                       Apollo    React Query                 │
│       or   careful                   or Relay  + graphql-request           │
│      SWR   design                                                           │
│                                                                              │
│  ─────────────────────────────────────────────────────────────────────      │
│                                                                              │
│  Additional Factors:                                                         │
│                                                                              │
│  Bundle size matters?                                                       │
│  • SWR (~4KB) < React Query (~13KB) < Apollo (~35KB+)                       │
│                                                                              │
│  Team experience?                                                            │
│  • New to data fetching → SWR                                               │
│  • Experienced with React → React Query                                     │
│  • GraphQL experts → Apollo                                                 │
│                                                                              │
│  Offline requirements?                                                       │
│  • React Query has best offline mutation story                              │
│  • Apollo has good offline support too                                      │
│  • SWR is limited                                                           │
│                                                                              │
│  Server Components?                                                          │
│  • All work with hydration                                                  │
│  • Consider Server Components for initial load                              │
│  • Client library for interactivity                                         │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

By Application Type

┌─────────────────────────────────────────────────────────────────────────────┐
│                    RECOMMENDATIONS BY APP TYPE                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  Content Sites / Blogs / Marketing                                          │
│  ─────────────────────────────────                                           │
│  Primary: Server Components                                                 │
│  Secondary: SWR for interactive parts                                       │
│  Reasoning: Mostly static, SEO important, minimal interactivity            │
│                                                                              │
│  SaaS Dashboards                                                            │
│  ───────────────                                                             │
│  Primary: React Query                                                       │
│  Secondary: Server Components for initial load                              │
│  Reasoning: Complex mutations, cache invalidation, real-time updates       │
│                                                                              │
│  E-commerce                                                                  │
│  ──────────                                                                  │
│  Primary: React Query or Apollo                                             │
│  Secondary: Server Components for catalog                                   │
│  Reasoning: Cart state, inventory, optimistic updates                       │
│                                                                              │
│  Social / Collaborative Apps                                                 │
│  ───────────────────────────                                                 │
│  Primary: Apollo (if GraphQL) or React Query                                │
│  Reasoning: Normalized cache valuable, complex relationships               │
│                                                                              │
│  Internal Tools                                                              │
│  ──────────────                                                              │
│  Primary: React Query or SWR                                                │
│  Reasoning: Developer speed > bundle size, forms-heavy                     │
│                                                                              │
│  Mobile Apps (React Native)                                                 │
│  ──────────────────────────                                                  │
│  Primary: React Query                                                       │
│  Reasoning: Offline support, good mobile perf, smaller than Apollo         │
│                                                                              │
│  Real-time Heavy (Chat, Gaming)                                             │
│  ──────────────────────────────                                              │
│  Primary: Custom solution or Apollo subscriptions                           │
│  Reasoning: May need different patterns entirely (WebSockets, CRDT)        │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Anti-Patterns to Avoid

┌─────────────────────────────────────────────────────────────────────────────┐
│                    DATA FETCHING ANTI-PATTERNS                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  1. Choosing Based on Popularity                                            │
│  ───────────────────────────────                                             │
│  ❌ "React Query has more stars, so it's better"                            │
│  ✅ "React Query fits our cache invalidation needs"                         │
│                                                                              │
│  2. GraphQL + React Query as Default                                        │
│  ─────────────────────────────────                                           │
│  ❌ Using GraphQL but ignoring Apollo's normalized cache                    │
│  ✅ If you chose GraphQL for the graph, use a graph-aware client           │
│                                                                              │
│  3. Over-engineering Simple Apps                                            │
│  ────────────────────────────────                                            │
│  ❌ Apollo for a blog with 5 API calls                                      │
│  ✅ Server Components + SWR for simple interactivity                        │
│                                                                              │
│  4. Under-engineering Complex Apps                                          │
│  ─────────────────────────────────                                           │
│  ❌ SWR with manual cache updates everywhere                                │
│  ✅ React Query with proper mutation/invalidation patterns                  │
│                                                                              │
│  5. Ignoring Server Components                                              │
│  ─────────────────────────────                                               │
│  ❌ Client-fetching everything in Next.js 14+                               │
│  ✅ Server Components for initial load, client for interactivity           │
│                                                                              │
│  6. Premature Optimization                                                  │
│  ─────────────────────────                                                   │
│  ❌ "We might need normalized cache someday"                                │
│  ✅ Start simple, migrate when you hit real limits                         │
│                                                                              │
│  7. Mixing Libraries                                                        │
│  ──────────────────                                                          │
│  ❌ React Query for some things, SWR for others, Apollo for GraphQL        │
│  ✅ One primary library, consistent patterns                               │
│                                                                              │
│  8. Fetching in Components Without Strategy                                 │
│  ────────────────────────────────────────                                    │
│  ❌ useEffect + fetch scattered everywhere                                  │
│  ✅ Centralized hooks with consistent patterns                             │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Quick Reference

Comparison At a Glance

┌─────────────────────────────────────────────────────────────────────────────┐
│                    QUICK COMPARISON                                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│                    Server     SWR      React     Apollo                     │
│                    Comps              Query                                  │
│  ─────────────────────────────────────────────────────────────────────      │
│  Bundle Size       0 KB      ~4KB     ~13KB     ~35KB+                      │
│  Learning Curve    Low       Low      Medium    High                        │
│  Cache Type        None*     URL      Query-key Normalized                  │
│  Mutations         N/A       Basic    Advanced  Advanced                    │
│  DevTools          N/A       Basic    Excellent Good                        │
│  Offline           N/A       Limited  Good      Good                        │
│  Real-time         N/A       Polling  Polling   Subscriptions               │
│  TypeScript        Native    Good     Excellent Good                        │
│                                                                              │
│  * Server Components use request-time caching, not client cache            │
│                                                                              │
│  ─────────────────────────────────────────────────────────────────────      │
│                                                                              │
│  Best For:                                                                   │
│  • Server Components: SEO pages, initial data, simple apps                 │
│  • SWR: Simple apps, read-heavy, small teams                               │
│  • React Query: Complex apps, REST/RPC, most production apps               │
│  • Apollo: GraphQL-native, complex relationships, large teams              │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Migration Paths

┌─────────────────────────────────────────────────────────────────────────────┐
│                    COMMON MIGRATION PATHS                                    │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                              │
│  fetch + useState → SWR                                                     │
│  ───────────────────────                                                     │
│  Difficulty: Easy                                                           │
│  Strategy: Replace useEffect/fetch with useSWR one component at a time    │
│                                                                              │
│  SWR → React Query                                                          │
│  ──────────────────                                                          │
│  Difficulty: Medium                                                         │
│  Strategy: Similar API, add mutation patterns, update cache strategy       │
│                                                                              │
│  React Query → Apollo                                                       │
│  ───────────────────                                                         │
│  Difficulty: High                                                           │
│  Strategy: Requires GraphQL migration, fundamentally different model       │
│  Consider: Do you actually need normalized cache?                          │
│                                                                              │
│  Any → Server Components                                                    │
│  ─────────────────────                                                       │
│  Difficulty: Medium                                                         │
│  Strategy: Move non-interactive fetches to server, keep client for UI     │
│                                                                              │
└─────────────────────────────────────────────────────────────────────────────┘

Closing Thoughts

The data fetching landscape is more nuanced than feature comparisons suggest. The "best" solution depends on:

  1. Your data patterns — Graph-shaped data with relationships? Consider Apollo. Document-based with complex mutations? React Query. Simple CRUD? SWR or Server Components.

  2. Your team — A small team shouldn't pay the Apollo learning curve cost for a simple app. A large team with GraphQL expertise shouldn't use React Query with manual cache syncing.

  3. Your trajectory — Where is your app going? Starting simple doesn't mean you can't migrate. But choosing prematurely complex means paying costs you might never need.

My default recommendations:

  • Start with Server Components for everything that doesn't need client interactivity
  • Add SWR or React Query for client-side needs
  • Choose React Query over SWR if you need complex mutations or cache invalidation
  • Choose Apollo only if you're committed to GraphQL AND need the normalized cache
  • Don't mix libraries — pick one and build consistent patterns

The goal isn't to pick the "most powerful" library. It's to pick the one that makes your specific data patterns easy to work with while leaving room to grow.


Your data fetching architecture should feel obvious to the next developer who joins your team. If it requires a document to explain, you might be over-engineering.

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.