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