Server State Management and Data Fetching
1. React Query (TanStack Query) Integration
| Feature | API | Description | Use Case |
|---|---|---|---|
| useQuery | useQuery({ queryKey, queryFn }) |
Fetch and cache data with automatic refetching | GET requests, read-only data fetching |
| useMutation | useMutation({ mutationFn }) |
Perform create/update/delete operations | POST/PUT/DELETE requests, data mutations |
| Query Keys | ['users', id, filters] |
Unique identifier for cached queries | Cache management, invalidation, dependencies |
| Stale Time | staleTime: 5 * 60 * 1000 |
Duration data considered fresh | Reduce unnecessary refetches, improve performance |
| Cache Time | cacheTime: 10 * 60 * 1000 |
How long inactive data stays in cache | Memory management, garbage collection |
| Refetch Strategies | refetchOnWindowFocus, refetchOnMount |
Auto-refetch triggers | Keep data fresh on user interactions |
| Pagination | useInfiniteQuery, keepPreviousData |
Infinite scroll and paginated queries | Lists with load more, pagination UIs |
| Optimistic Updates | onMutate, onError, onSettled |
Update UI before server confirmation | Instant feedback, rollback on error |
| Query Invalidation | queryClient.invalidateQueries() |
Mark queries as stale, trigger refetch | Update related data after mutations |
| Prefetching | queryClient.prefetchQuery() |
Load data before component mounts | Hover states, route transitions |
Example: Complete React Query setup
import { QueryClient, QueryClientProvider, useQuery, useMutation } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create query client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: true,
retry: 1
}
}
});
// App setup
function App() {
return (
<QueryClientProvider client={queryClient}>
<UserList />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
// Fetch users
function UserList() {
const {
data: users,
isLoading,
isError,
error,
refetch
} = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
});
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error: {error.message}</div>;
return (
<div>
<button onClick={refetch}>Refresh</button>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Example: Mutations with optimistic updates
import { useQueryClient, useMutation } from '@tanstack/react-query';
function TodoList() {
const queryClient = useQueryClient();
// Fetch todos
const { data: todos } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
});
// Add todo mutation
const addTodoMutation = useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo)
}).then(res => res.json()),
// Optimistic update
onMutate: async (newTodo) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['todos'] });
// Snapshot previous value
const previousTodos = queryClient.getQueryData(['todos']);
// Optimistically update
queryClient.setQueryData(['todos'], (old) => [
...old,
{ ...newTodo, id: 'temp-' + Date.now() }
]);
// Return context for rollback
return { previousTodos };
},
// Rollback on error
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
toast.error('Failed to add todo');
},
// Refetch on success
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
const handleAdd = (text) => {
addTodoMutation.mutate({ text, completed: false });
};
return (
<div>
<AddTodoForm onAdd={handleAdd} />
{todos?.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</div>
);
}
Example: Infinite scroll with useInfiniteQuery
import { useInfiniteQuery } from '@tanstack/react-query';
function InfiniteList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 1 }) => {
const response = await fetch(`/api/posts?page=${pageParam}`);
return response.json();
},
getNextPageParam: (lastPage, pages) => {
return lastPage.hasMore ? pages.length + 1 : undefined;
}
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Note: React Query excels at server state management with automatic caching, background updates,
and deduplication. Use query keys as dependencies - when they change, queries refetch. Combine with optimistic
updates for instant UX.
2. SWR (Stale-While-Revalidate) Patterns
| Feature | API | Description | Benefits |
|---|---|---|---|
| useSWR | useSWR(key, fetcher, options) |
Fetch data with automatic revalidation | Simple API, built-in cache, auto-refetch |
| Key-based Cache | '/api/user/' + id |
String/array key for caching | Automatic deduplication, global cache |
| Revalidation | revalidateOnFocus, revalidateOnReconnect |
Auto-refetch triggers | Keep data fresh without manual intervention |
| mutate | mutate(key, data, options) |
Programmatically update cache | Optimistic updates, cache invalidation |
| useSWRMutation | useSWRMutation(key, fetcher) |
Trigger mutations manually | POST/PUT/DELETE operations |
| Suspense Mode | suspense: true |
Use with React Suspense | Declarative loading states |
| Pagination | useSWRInfinite |
Infinite loading and pagination | Load more, cursor-based pagination |
| Prefetch | preload(key, fetcher) |
Load data before rendering | Instant page transitions |
Example: Basic SWR usage with revalidation
import useSWR from 'swr';
// Fetcher function
const fetcher = (url) => fetch(url).then(res => res.json());
function Profile() {
const { data, error, isLoading, mutate } = useSWR(
'/api/user',
fetcher,
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
refreshInterval: 30000 // Refresh every 30s
}
);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Failed to load</div>;
return (
<div>
<h1>{data.name}</h1>
<button onClick={() => mutate()}>Refresh</button>
</div>
);
}
// Conditional fetching
function User({ id }) {
const { data } = useSWR(
id ? `/api/user/${id}` : null, // null = don't fetch
fetcher
);
return data ? <div>{data.name}</div> : null;
}
// Dependent queries
function UserPosts({ userId }) {
const { data: user } = useSWR(`/api/user/${userId}`, fetcher);
const { data: posts } = useSWR(
user ? `/api/user/${user.id}/posts` : null,
fetcher
);
return posts?.map(p => <Post key={p.id} post={p} />);
}
Example: Optimistic updates with mutate
import useSWR, { useSWRConfig } from 'swr';
function TodoList() {
const { data: todos } = useSWR('/api/todos', fetcher);
const { mutate } = useSWRConfig();
const addTodo = async (text) => {
const newTodo = { id: Date.now(), text, completed: false };
// Optimistic update - update cache immediately
mutate(
'/api/todos',
[...todos, newTodo],
false // Don't revalidate yet
);
try {
// Send to server
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo)
});
// Revalidate to get server data
mutate('/api/todos');
} catch (error) {
// Rollback on error
mutate('/api/todos', todos);
toast.error('Failed to add todo');
}
};
const toggleTodo = async (id) => {
// Optimistic update
const updated = todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
);
mutate('/api/todos', updated, false);
try {
await fetch(`/api/todos/${id}`, { method: 'PATCH' });
mutate('/api/todos');
} catch (error) {
mutate('/api/todos', todos);
}
};
return (
<div>
{todos?.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => toggleTodo(todo.id)}
/>
))}
</div>
);
}
Example: Infinite scroll with useSWRInfinite
import { useSWRInfinite } from 'swr';
function PostList() {
const getKey = (pageIndex, previousPageData) => {
// Reached the end
if (previousPageData && !previousPageData.hasMore) return null;
// First page
return `/api/posts?page=${pageIndex + 1}`;
};
const {
data,
size,
setSize,
isLoading,
isValidating
} = useSWRInfinite(getKey, fetcher);
const posts = data ? data.flatMap(page => page.posts) : [];
const hasMore = data?.[data.length - 1]?.hasMore;
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{hasMore && (
<button
onClick={() => setSize(size + 1)}
disabled={isValidating}
>
{isValidating ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
Note: SWR is lightweight (~5KB) with excellent TypeScript support. The "stale-while-revalidate"
strategy shows cached data instantly while fetching fresh data in background. Great for Next.js apps with
built-in integration.
3. Apollo Client GraphQL State Management
| Feature | API | Description | Use Case |
|---|---|---|---|
| useQuery | useQuery(QUERY, { variables }) |
Execute GraphQL query and cache result | Fetch data with automatic caching |
| useMutation | useMutation(MUTATION) |
Execute GraphQL mutation | Create/update/delete operations |
| useLazyQuery | useLazyQuery(QUERY) |
Query triggered manually (not on mount) | Search, on-demand fetching |
| Cache Policies | cache-first, network-only, cache-and-network |
Control cache behavior | Balance freshness vs performance |
| Normalized Cache | InMemoryCache with typePolicies |
Automatic normalization by ID | Efficient updates, deduplication |
| Optimistic Response | optimisticResponse: { ... } |
Instant UI update before server response | Fast feedback on mutations |
| refetchQueries | refetchQueries: ['GetUsers'] |
Auto-refetch queries after mutation | Keep related data in sync |
| Local State | @client directive, reactive variables |
Manage local client state | UI state alongside server data |
Example: Apollo Client setup and queries
import { ApolloClient, InMemoryCache, ApolloProvider, gql, useQuery } from '@apollo/client';
// Create Apollo Client
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache({
typePolicies: {
User: {
keyFields: ['id']
}
}
})
});
// App setup
function App() {
return (
<ApolloProvider client={client}>
<UserList />
</ApolloProvider>
);
}
// Define query
const GET_USERS = gql`
query GetUsers($limit: Int!) {
users(limit: $limit) {
id
name
email
posts {
id
title
}
}
}
`;
// Component with query
function UserList() {
const { loading, error, data, refetch } = useQuery(GET_USERS, {
variables: { limit: 10 },
fetchPolicy: 'cache-first' // Try cache first
});
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<button onClick={() => refetch()}>Refresh</button>
{data.users.map(user => (
<div key={user.id}>
{user.name} - {user.posts.length} posts
</div>
))}
</div>
);
}
Example: Mutations with optimistic updates
import { useMutation, gql } from '@apollo/client';
const ADD_TODO = gql`
mutation AddTodo($text: String!) {
addTodo(text: $text) {
id
text
completed
}
}
`;
const GET_TODOS = gql`
query GetTodos {
todos {
id
text
completed
}
}
`;
function TodoForm() {
const [addTodo] = useMutation(ADD_TODO, {
// Optimistic response
optimisticResponse: {
addTodo: {
__typename: 'Todo',
id: 'temp-id',
text: inputValue,
completed: false
}
},
// Update cache manually
update(cache, { data: { addTodo } }) {
const existing = cache.readQuery({ query: GET_TODOS });
cache.writeQuery({
query: GET_TODOS,
data: {
todos: [...existing.todos, addTodo]
}
});
},
// Or refetch queries
refetchQueries: [{ query: GET_TODOS }]
});
const handleSubmit = (text) => {
addTodo({ variables: { text } });
};
return <form onSubmit={handleSubmit}>...</form>;
}
Example: Local state with reactive variables
import { makeVar, useReactiveVar } from '@apollo/client';
// Create reactive variable for local state
const cartItemsVar = makeVar([]);
const isLoggedInVar = makeVar(false);
// Read and write reactive variables
function ShoppingCart() {
const cartItems = useReactiveVar(cartItemsVar);
const addItem = (item) => {
cartItemsVar([...cartItemsVar(), item]);
};
const removeItem = (id) => {
cartItemsVar(cartItemsVar().filter(item => item.id !== id));
};
return (
<div>
{cartItems.map(item => (
<CartItem
key={item.id}
item={item}
onRemove={() => removeItem(item.id)}
/>
))}
</div>
);
}
// Use in queries with @client directive
const GET_CART = gql`
query GetCart {
cartItems @client
isLoggedIn @client
}
`;
function Cart() {
const { data } = useQuery(GET_CART);
return (
<div>
{data.isLoggedIn ? (
<div>{data.cartItems.length} items</div>
) : (
<div>Please log in</div>
)}
</div>
);
}
Note: Apollo Client provides comprehensive GraphQL state management with normalized caching,
optimistic updates, and local state. Automatic cache updates for many cases. Use reactive variables for UI
state alongside server data.
4. Server State vs Client State Separation
| State Type | Characteristics | Management | Examples |
|---|---|---|---|
| Server State | Persisted remotely, asynchronous, shared, can be stale | React Query, SWR, Apollo Client | User data, posts, products, API responses |
| Client State | Local to browser, synchronous, not shared, always fresh | useState, useReducer, Context, Zustand | UI state, form inputs, modals, theme, filters |
| Hybrid State | Derived from server, modified locally | Combine both approaches | Filtered/sorted server data, draft edits |
| Ownership | Server owns server state, client owns UI state | Clear boundaries prevent conflicts | Don't duplicate server data in client state |
| Persistence | Server state persists, client state ephemeral | Server state survives refreshes | User profile (server) vs sidebar open (client) |
| Sync Strategy | Server state needs sync, client state doesn't | Polling, websockets, invalidation | Real-time updates, stale data handling |
| Cache Strategy | Server state cached, client state in memory | Different cache policies | Stale-while-revalidate vs direct access |
| Error Handling | Server state can fail, client state reliable | Network errors, retries, fallbacks | Loading/error states for server data |
Example: Clear separation of server and client state
import { useQuery } from '@tanstack/react-query';
import { create } from 'zustand';
// SERVER STATE - managed by React Query
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const res = await fetch('/api/users');
return res.json();
}
});
}
// CLIENT STATE - managed by Zustand
const useUIStore = create((set) => ({
sidebarOpen: false,
theme: 'light',
searchQuery: '',
selectedUserId: null,
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
setSearchQuery: (query) => set({ searchQuery: query }),
selectUser: (id) => set({ selectedUserId: id })
}));
// Component combines both
function UserDashboard() {
// Server state
const { data: users, isLoading } = useUsers();
// Client state
const {
searchQuery,
selectedUserId,
setSearchQuery,
selectUser
} = useUIStore();
// Derived from both (hybrid)
const filteredUsers = users?.filter(u =>
u.name.toLowerCase().includes(searchQuery.toLowerCase())
) || [];
const selectedUser = users?.find(u => u.id === selectedUserId);
return (
<div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search users..."
/>
{isLoading ? (
<Spinner />
) : (
<>
<UserList
users={filteredUsers}
onSelect={selectUser}
/>
{selectedUser && (
<UserDetails user={selectedUser} />
)}
</>
)}
</div>
);
}
Example: Anti-pattern - duplicating server state in client state
// ❌ ANTI-PATTERN - Don't do this
function BadExample() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data); // Duplicating server data
setLoading(false);
});
}, []);
// Problem: No caching, no auto-refetch, manual loading state
return <div>...</div>;
}
// ✅ BETTER - Use server state library
function GoodExample() {
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
// Automatic caching, refetching, error handling
return <div>...</div>;
}
// ✅ CORRECT - Client state for UI, server state for data
function BestExample() {
// Server state
const { data: users } = useQuery(['users'], fetchUsers);
// Client state
const [selectedId, setSelectedId] = useState(null);
const [sortOrder, setSortOrder] = useState('asc');
// Derive combined state
const sortedUsers = useMemo(() => {
if (!users) return [];
return [...users].sort((a, b) =>
sortOrder === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
);
}, [users, sortOrder]);
return <div>...</div>;
}
Warning: Don't duplicate server state in client state (useState/Redux). Use specialized
libraries (React Query/SWR) for server data. Keep client state for UI concerns only. This prevents sync issues
and reduces code complexity.
5. Cache Invalidation and State Synchronization
| Strategy | Implementation | Use Case | Trade-offs |
|---|---|---|---|
| Manual Invalidation | queryClient.invalidateQueries(['key']) |
Invalidate after mutations | Precise control, requires manual management |
| Auto-refetch | refetchQueries: ['users'] |
Automatic after mutations | Simple but may over-fetch |
| Time-based | staleTime, cacheTime, refetchInterval |
Periodic updates | Predictable but may be unnecessary |
| Event-based | refetchOnWindowFocus, refetchOnReconnect |
User interaction triggers | Fresh on user return, can be aggressive |
| WebSocket Sync | socket.on('update', () => invalidate()) |
Real-time updates | Always fresh, requires WebSocket infrastructure |
| Optimistic Update | setQueryData + rollback on error |
Instant UI updates | Fast UX but complex error handling |
| Partial Update | setQueryData with selective merge |
Update specific fields | Efficient but needs careful merging |
| Polling | refetchInterval: 5000 |
Continuous sync | Simple but resource-intensive |
Example: Cache invalidation patterns
import { useQueryClient, useMutation } from '@tanstack/react-query';
function UserManagement() {
const queryClient = useQueryClient();
// Create user mutation
const createUser = useMutation({
mutationFn: (newUser) => fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser)
}).then(r => r.json()),
onSuccess: () => {
// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: ['users'] });
// Or invalidate specific queries
queryClient.invalidateQueries({
queryKey: ['users', 'list']
});
}
});
// Update user with optimistic update
const updateUser = useMutation({
mutationFn: ({ id, updates }) =>
fetch(`/api/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(updates)
}).then(r => r.json()),
onMutate: async ({ id, updates }) => {
// Cancel ongoing queries
await queryClient.cancelQueries({ queryKey: ['users', id] });
// Snapshot for rollback
const previous = queryClient.getQueryData(['users', id]);
// Optimistic update
queryClient.setQueryData(['users', id], (old) => ({
...old,
...updates
}));
return { previous };
},
onError: (err, { id }, context) => {
// Rollback on error
queryClient.setQueryData(['users', id], context.previous);
},
onSettled: (data, error, { id }) => {
// Refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['users', id] });
}
});
// Delete user
const deleteUser = useMutation({
mutationFn: (id) => fetch(`/api/users/${id}`, {
method: 'DELETE'
}),
onSuccess: (data, id) => {
// Remove from cache
queryClient.removeQueries({ queryKey: ['users', id] });
// Update list cache
queryClient.setQueryData(['users', 'list'], (old) =>
old.filter(u => u.id !== id)
);
}
});
return <div>...</div>;
}
Example: WebSocket-based real-time sync
import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
function useRealtimeSync() {
const queryClient = useQueryClient();
useEffect(() => {
const socket = new WebSocket('ws://api.example.com');
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'USER_CREATED':
// Invalidate user list
queryClient.invalidateQueries({ queryKey: ['users'] });
break;
case 'USER_UPDATED':
// Update specific user
queryClient.setQueryData(
['users', message.userId],
message.data
);
// Also update in list
queryClient.setQueryData(['users', 'list'], (old) =>
old.map(u =>
u.id === message.userId ? message.data : u
)
);
break;
case 'USER_DELETED':
// Remove from cache
queryClient.removeQueries({
queryKey: ['users', message.userId]
});
queryClient.setQueryData(['users', 'list'], (old) =>
old.filter(u => u.id !== message.userId)
);
break;
}
};
return () => socket.close();
}, [queryClient]);
}
function App() {
useRealtimeSync();
return <UserDashboard />;
}
Note: Cache invalidation is hard but crucial for data consistency. Use invalidateQueries after
mutations, implement optimistic updates for instant feedback, and consider WebSockets for real-time apps. Always
provide rollback mechanisms.
6. Offline State Management with Service Workers
| Pattern | Implementation | Description | Benefits |
|---|---|---|---|
| Service Worker Cache | caches.match(), caches.put() |
Cache API responses for offline access | Work without network, instant loading |
| Cache Strategies | Cache First, Network First, Stale While Revalidate |
Different strategies for different resources | Balance freshness vs offline availability |
| Background Sync | registration.sync.register() |
Queue actions while offline, sync when online | Reliable data submission despite connectivity |
| IndexedDB Queue | store pending mutations in IndexedDB |
Persist failed requests locally | Survive page refreshes, manual retry |
| Online/Offline Detection | navigator.onLine, online/offline events |
Track network status | Adapt UI based on connectivity |
| Conflict Resolution | Last-write-wins, merge, manual |
Handle concurrent offline edits | Data integrity when syncing |
| Version Vectors | Track edit history per client |
Detect conflicts in distributed edits | Sophisticated conflict detection |
| Optimistic Sync | Update UI immediately, sync in background |
Assume success, handle failures gracefully | Responsive offline-first UX |
Example: Offline-first with React Query and service worker
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
// Track online status
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
// Offline-aware query
function useOfflineQuery(key, fetcher) {
const isOnline = useOnlineStatus();
return useQuery({
queryKey: key,
queryFn: fetcher,
staleTime: Infinity, // Cache forever
cacheTime: Infinity,
refetchOnWindowFocus: isOnline,
refetchOnReconnect: isOnline,
retry: isOnline ? 3 : 0 // Don't retry when offline
});
}
// Queue mutations when offline
function useOfflineMutation(mutationFn) {
const queryClient = useQueryClient();
const isOnline = useOnlineStatus();
const [queue, setQueue] = useState([]);
const mutation = useMutation({
mutationFn,
onSuccess: () => {
queryClient.invalidateQueries();
},
onError: (error, variables) => {
if (!isOnline) {
// Queue for later
setQueue(q => [...q, variables]);
saveToIndexedDB('mutation-queue', variables);
}
}
});
// Process queue when back online
useEffect(() => {
if (isOnline && queue.length > 0) {
queue.forEach(variables => {
mutation.mutate(variables);
});
setQueue([]);
clearIndexedDB('mutation-queue');
}
}, [isOnline, queue]);
return mutation;
}
// Component usage
function TodoApp() {
const isOnline = useOnlineStatus();
const { data: todos } = useOfflineQuery(
['todos'],
async () => {
const res = await fetch('/api/todos');
return res.json();
}
);
const addTodo = useOfflineMutation(
async (newTodo) => {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo)
});
return res.json();
}
);
return (
<div>
{!isOnline && (
<div className="offline-banner">
You're offline. Changes will sync when back online.
</div>
)}
<TodoList todos={todos} />
<button onClick={() => addTodo.mutate({ text: 'New todo' })}>
Add Todo
</button>
</div>
);
}
Example: Service worker with cache strategies
// service-worker.js
const CACHE_NAME = 'app-v1';
const DYNAMIC_CACHE = 'dynamic-v1';
// Install - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/app.js',
'/styles.css'
]);
})
);
});
// Fetch - different strategies for different requests
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API requests - Network First (fresh data when online)
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((response) => {
// Cache successful responses
const clonedResponse = response.clone();
caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(request, clonedResponse);
});
return response;
})
.catch(() => {
// Fallback to cache when offline
return caches.match(request);
})
);
return;
}
// Static assets - Cache First (instant loading)
event.respondWith(
caches.match(request).then((cachedResponse) => {
return cachedResponse || fetch(request).then((response) => {
return caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(request, response.clone());
return response;
});
});
})
);
});
// Background Sync - retry failed requests
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-mutations') {
event.waitUntil(
getMutationQueue().then((mutations) => {
return Promise.all(
mutations.map((mutation) =>
fetch(mutation.url, mutation.options)
.then(() => removeMutation(mutation.id))
)
);
})
);
}
});
Warning: Offline-first apps require careful conflict resolution. Consider using CRDTs
(Conflict-free Replicated Data Types) for complex scenarios. Always show offline indicators and sync status to
users. Test thoroughly with throttled/offline network conditions.
Section 15 Key Takeaways
- React Query - Powerful server state with caching, refetching, optimistic updates, and pagination
- SWR - Lightweight stale-while-revalidate pattern, great for Next.js, simple API
- Apollo Client - GraphQL state management with normalized cache and local state
- Separation - Keep server state (React Query/SWR) separate from client state (Zustand/Context)
- Cache invalidation - Use after mutations, implement optimistic updates, WebSockets for real-time
- Offline-first - Service workers, background sync, queue mutations, handle conflicts gracefully