Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

React API Integration: Data Fetching Mastery

March 9, 2025 · 9 min read

React, React Query, API, Data Fetching, Interview Prep

Introduction

Every React app needs to fetch data. While fetch and useEffect work for simple cases, production apps need caching, deduplication, background refetching, and error handling. This article covers the spectrum from basic patterns to React Query mastery.


Basic Fetch Pattern

The foundation: useEffect with state management.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchUser() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch');
        const data = await response.json();

        if (!cancelled) {
          setUser(data);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err.message);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchUser();

    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage message={error} />;
  return <Profile user={user} />;
}

Race Condition Prevention

The cancelled flag is crucial. Without it, if userId changes rapidly, old requests can overwrite newer data. This is a common source of bugs in production apps.


Custom Data Fetching Hook

Extract the pattern into a reusable hook:

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchData() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }
        const json = await response.json();

        if (!cancelled) {
          setData(json);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }

    fetchData();

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

// Usage
function UserList() {
  const { data: users, loading, error } = useFetch('/api/users');

  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

React Query: Production Data Fetching

React Query (TanStack Query) solves problems the basic pattern doesn't:

  • Caching
  • Background refetching
  • Request deduplication
  • Pagination and infinite scroll
  • Mutations with cache updates
  • Optimistic updates

Setup

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      gcTime: 10 * 60 * 1000,   // 10 minutes (formerly cacheTime)
      retry: 1,
      refetchOnWindowFocus: true,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Router />
    </QueryClientProvider>
  );
}

Basic Query

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

function UserProfile({ userId }) {
  const {
    data: user,
    isLoading,
    isError,
    error,
    refetch
  } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <Spinner />;
  if (isError) return <Error message={error.message} onRetry={refetch} />;

  return <Profile user={user} />;
}

Query Keys

Query keys identify cached data. Structure them hierarchically:

// User-related queries
['users']                    // All users
['users', userId]            // Specific user
['users', userId, 'posts']   // User's posts

// Filtered data
['products', { category: 'electronics', sort: 'price' }]

// Paginated data
['products', { page: 1, limit: 20 }]

Dependent Queries

When one query depends on another:

function UserPosts({ userId }) {
  // First query
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // Dependent query - only runs when user is available
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchPosts(user.id),
    enabled: !!user, // Only fetch when user exists
  });

  return (
    <div>
      <UserHeader user={user} />
      <PostList posts={posts} />
    </div>
  );
}

Mutations

For creating, updating, or deleting data:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function CreatePostForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (newPost) => createPost(newPost),
    onSuccess: () => {
      // Invalidate and refetch posts
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
    onError: (error) => {
      toast.error(`Failed to create post: ${error.message}`);
    },
  });

  const handleSubmit = (data) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

Optimistic Updates

Update UI immediately, rollback on error:

function TodoItem({ todo }) {
  const queryClient = useQueryClient();

  const toggleMutation = useMutation({
    mutationFn: (id) => toggleTodo(id),

    onMutate: async (id) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // Snapshot previous value
      const previousTodos = queryClient.getQueryData(['todos']);

      // Optimistically update
      queryClient.setQueryData(['todos'], (old) =>
        old.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
      );

      // Return context for rollback
      return { previousTodos };
    },

    onError: (err, id, context) => {
      // Rollback on error
      queryClient.setQueryData(['todos'], context.previousTodos);
      toast.error('Failed to update');
    },

    onSettled: () => {
      // Always refetch after error or success
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    },
  });

  return (
    <li onClick={() => toggleMutation.mutate(todo.id)}>
      {todo.completed ? '✓' : '○'} {todo.text}
    </li>
  );
}

Not Everything Needs Optimistic Updates

When building Glucoplate, I learned that optimistic updates aren't always the answer. Sometimes restructuring your API to return more data upfront eliminates the need for complex rollback logic. If the client already has everything it needs to show the result, a simple loading state works fine. Profile your actual UX before adding complexity.


Pagination

function ProductList() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ['products', page],
    queryFn: () => fetchProducts({ page, limit: 20 }),
    placeholderData: (previousData) => previousData, // Keep previous data while loading
  });

  return (
    <div>
      <ProductGrid
        products={data?.products}
        style={{ opacity: isPlaceholderData ? 0.5 : 1 }}
      />

      <Pagination
        currentPage={page}
        totalPages={data?.totalPages}
        onPageChange={setPage}
        disabled={isLoading}
      />
    </div>
  );
}

Infinite Scroll

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

function InfiniteProductList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['products'],
    queryFn: ({ pageParam = 1 }) => fetchProducts({ page: pageParam }),
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  const products = data?.pages.flatMap(page => page.products) ?? [];

  return (
    <div>
      <ProductGrid products={products} />

      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Error Handling Patterns

Query-Level Error Boundary

function UserProfile({ userId }) {
  const { data, isError, error, refetch } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    retry: (failureCount, error) => {
      // Don't retry on 404
      if (error.status === 404) return false;
      return failureCount < 3;
    },
  });

  if (isError) {
    if (error.status === 404) {
      return <UserNotFound />;
    }
    return (
      <ErrorCard
        message={error.message}
        onRetry={refetch}
      />
    );
  }

  return <Profile user={data} />;
}

Global Error Handling

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        if (error.status === 401) {
          // Redirect to login
          window.location.href = '/login';
        }
      },
    },
    mutations: {
      onError: (error) => {
        toast.error(`Error: ${error.message}`);
      },
    },
  },
});

Custom Query Hooks

Encapsulate query logic in custom hooks:

// hooks/useUser.js
export function useUser(userId) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 10 * 60 * 1000, // Users don't change often
  });
}

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

  return useMutation({
    mutationFn: ({ userId, updates }) => updateUser(userId, updates),
    onSuccess: (data, { userId }) => {
      queryClient.setQueryData(['user', userId], data);
    },
  });
}

// Usage
function UserSettings({ userId }) {
  const { data: user, isLoading } = useUser(userId);
  const updateMutation = useUpdateUser();

  const handleSave = (updates) => {
    updateMutation.mutate({ userId, updates });
  };

  // ...
}

Interview Questions

Q1: Why use React Query instead of useEffect + fetch?

Answer: React Query solves problems that useEffect + fetch doesn't:

  • Caching - Avoid refetching the same data
  • Deduplication - Multiple components requesting same data = one request
  • Background updates - Stale-while-revalidate pattern
  • Retry logic - Automatic retries with exponential backoff
  • Mutations - Cache invalidation and optimistic updates
  • DevTools - Inspect cache state and queries

Q2: Explain staleTime vs gcTime (cacheTime).

Answer:

  • staleTime - How long data is considered "fresh". Fresh data won't trigger a refetch.
  • gcTime - How long inactive data stays in cache before garbage collection.

Example: staleTime: 5min, gcTime: 10min

  • Data fetched, stays fresh for 5 minutes
  • After 5 minutes, data is "stale" but still in cache
  • Stale data shown immediately while refetch happens in background
  • After 10 minutes of not being used, data is removed from cache

Q3: How do you handle optimistic updates?

Answer:

  1. In onMutate: Cancel ongoing queries, snapshot current data, optimistically update cache
  2. Return context with snapshot for potential rollback
  3. In onError: Rollback using the snapshot
  4. In onSettled: Invalidate queries to get fresh data

Q4: What's the purpose of query keys?

Answer: Query keys uniquely identify cached data and determine when to refetch:

  • Same key = cached data returned
  • Key changes = new fetch triggered
  • Keys are used for cache invalidation

Structure keys hierarchically for fine-grained invalidation:

invalidateQueries(['users'])         // All user queries
invalidateQueries(['users', userId]) // Specific user

Common Mistakes

1. Unstable Query Keys

// ❌ Object reference changes every render
useQuery({
  queryKey: ['products', { page, sort }], // OK
  queryFn: () => fetch({ page, sort }),   // OK
});

// But watch out for inline functions
// ❌ New function every render
queryFn: () => fetchProducts({ filters: getFilters() })

// ✅ Extract variables
const filters = getFilters();
queryFn: () => fetchProducts({ filters })

2. Not Handling Loading/Error States

// ❌ Crashes when data is undefined
const { data } = useQuery({ ... });
return <List items={data.items} />;

// ✅ Handle all states
const { data, isLoading, isError } = useQuery({ ... });
if (isLoading) return <Spinner />;
if (isError) return <Error />;
return <List items={data.items} />;

3. Forgetting to Invalidate After Mutations

// ❌ UI doesn't update after mutation
const mutation = useMutation({
  mutationFn: createTodo,
  // Missing: onSuccess with invalidateQueries
});

// ✅ Invalidate related queries
const mutation = useMutation({
  mutationFn: createTodo,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Summary

  • Basic pattern: useEffect + fetch with race condition prevention
  • React Query provides caching, deduplication, and background updates
  • Query keys uniquely identify cached data
  • Mutations for create/update/delete with cache invalidation
  • Optimistic updates provide instant feedback
  • Custom hooks encapsulate query logic for reuse

Next up: Forms and Validation with React Hook Form and Zod.


Part 5 of the React Developer Reference series.

← Previous

React Forms and Validation: Production Patterns

Next →

React Router: Complete Navigation Guide