Frontend Architecture
Part 9 of 11Data Fetching Architecture in React Apps
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:
-
Your data patterns — Graph-shaped data with relationships? Consider Apollo. Document-based with complex mutations? React Query. Simple CRUD? SWR or Server Components.
-
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.
-
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?