Skip to main content

Command Palette

Search for a command to run...

React Query: The Misunderstood Data-Fetching Library

Updated
7 min read
React Query: The Misunderstood Data-Fetching Library

Despite its name and reputation as a "data-fetching library," React Query doesn't actually fetch anything. It's a common misconception that needs clearing up.

What React Query Really Is

React Query is data-fetching agnostic. It's purely a caching and state management library that doesn't care how you fetch your data. Whether you use fetch(), axios, XMLHttpRequest, or even a custom GraphQL client, React Query treats them all the same.

Think of React Query as a sophisticated data orchestrator. You're responsible for choosing a data fetcher, then React Query handles storing it, synchronizing it across components, and only refetching after the specified stale time.

Understanding the Architecture

React Query's architecture separates concerns beautifully:

Your Responsibility (The Data Fetcher):

  • Choose your HTTP client (fetch, axios, etc.)

  • Define API endpoints and request structure

  • Handle request formatting (REST, GraphQL, etc.)

  • Configure authentication and headers

React Query's Responsibility (The Cache Manager):

  • Store fetched data in memory

  • Track cache freshness and staleness

  • Coordinate when to refetch

  • Manage loading and error states across your app

This separation means you have complete flexibility in how you retrieve data, while React Query handles the complex orchestration of keeping that data fresh and accessible.

You Can Use ANY Fetching Method

Here's the beauty of React Query's agnostic approach—all of these work equally well:

// Option 1: Native fetch()
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
});

// Option 2: Axios
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => axios.get(`/api/users/${userId}`).then(res => res.data)
});

// Option 3: GraphQL client
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => graphqlClient.request(GET_USER_QUERY, { userId })
});

// Option 4: Your custom API wrapper
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => myCustomApi.users.getById(userId)
});

Notice the pattern? React Query only cares that your queryFn returns a Promise. Everything else is up to you.

What React Query DOES Handle

React Query excels at server state management and data synchronization:

Caches results - Stores data in memory with configurable cache times, eliminating redundant requests

Manages loading/error states - Provides isLoading, isError, error, isFetching without manual useState calls

Handles refetching/invalidation - Automatically refetches stale data and invalidates caches when mutations occur

Prevents duplicate requests - Deduplicates simultaneous requests for the same data across your entire app

Background updates - Refetches data in the background when windows refocus, or network reconnects

Pagination/infinite scroll helpers - Built-in utilities for complex data loading patterns

Optimistic updates - Update UI immediately before the server confirms changes

Global state synchronization - Automatically updates all components using the same query when data changes

Request retry logic - Configurable retry attempts for failed requests

Garbage collection - Removes unused cache entries to prevent memory leaks

What React Query DOESN'T Do

Understanding the boundaries is just as important:

Make HTTP requests - You provide the fetching function

Know about REST/GraphQL - It's protocol-agnostic

Handle network layer - No built-in retry logic, interceptors, or request transformation (though you can add these yourself)

Our Real-World Implementation

In our production codebase, we chose the native fetch() API to make GraphQL requests. React Query manages the entire data lifecycle:

const useProject = (projectId) => {
  return useQuery({
    queryKey: ['project', projectId],
    queryFn: async () => {
      const response = await fetch('/graphql', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          query: `
            query GetProject($id: ID!) {
              project(id: $id) {
                id
                name
                description
                updatedAt
              }
            }
          `,
          variables: { id: projectId }
        })
      });

      const { data, errors } = await response.json();
      if (errors) throw new Error(errors[0].message);
      return data.project;
    },
    staleTime: 5 * 60 * 000, // React Query manages staleness
    cacheTime: 10 * 60 * 1000, // React Query manages cache
  });
};

We handle the GraphQL request formatting and network call. React Query handles caching that response, tracking when it becomes stale, managing loading states across components, and automatically refetching when needed.

Understanding staleTime vs cacheTime (gcTime)

Two of React Query's most important but confusing configurations are staleTime and cacheTime (renamed to gcTime in v5). They control different aspects of the data lifecycle:

staleTime - When data becomes "stale"

This determines how long data is considered "fresh." Fresh data won't trigger a refetch.

useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000, // 5 minutes
});
  • Default: 0 (immediately stale)

  • When staleTime expires, data becomes "stale" but remains in cache

  • Stale data triggers background refetches on window focus, component mount, or manual refetch

  • Fresh data (within staleTime) won't refetch even if you revisit the component

cacheTime/gcTime - When cache gets garbage collected

This determines how long unused data stays in memory after all components are unmounted.

useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000,     // Fresh for 5 minutes
  cacheTime: 10 * 60 * 1000,    // Cached for 10 minutes (v4)
  // gcTime: 10 * 60 * 1000,    // In v5, use gcTime instead
});
  • Default: 5 minutes

  • Starts counting down when the last component using this query unmounts

  • When gcTime expires, data is removed from memory completely

  • Next fetch will be a fresh request, not served from cache

How They Work Together:

Time:     0s -------- 5min -------- 10min -------- 15min
          |           |             |              |
Mount --> [--FRESH---][---STALE----]              |
          |           |             |              |
Unmount -------->     |             |              |
                      |  [--CACHED--][--DELETED--]

Real-world example:

// User profile - changes infrequently
useQuery({
  queryKey: ['profile'],
  queryFn: fetchProfile,
  staleTime: 10 * 60 * 1000,  // Don't refetch for 10 minutes
  gcTime: 30 * 60 * 1000,     // Keep in cache for 30 minutes
});

// Real-time stock prices - always fresh
useQuery({
  queryKey: ['stock', symbol],
  queryFn: fetchStock,
  staleTime: 0,               // Always stale, always refetch
  gcTime: 1 * 60 * 1000,      // Clear from cache after 1 minute
});

// Static reference data - rarely changes
useQuery({
  queryKey: ['countries'],
  queryFn: fetch

The Bottom Line

React Query isn't a data-fetching library—it's a server state management and synchronization library. This distinction matters because it means you maintain full control over your data layer while delegating the complex state orchestration to a battle-tested solution.

Why "State Management" Matters

Traditional state management libraries like Redux or Zustand are designed for client state—data that lives entirely in your application (UI state, form inputs, user preferences). Server state is fundamentally different:

Server State Characteristics:

  • Asynchronously fetched from remote sources

  • Can become outdated or "stale"

  • Shared across multiple components and users

  • Requires loading and error states

  • Needs periodic refreshing

  • Benefits of caching to reduce network requests

React Query specializes in server state. It solves problems that client state managers weren't designed for:

// Without React Query - manual state management
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [lastFetch, setLastFetch] = useState(null);

useEffect(() => {
  const fetchData = async () => {
    setIsLoading(true);
    try {
      const result = await fetch('/api/data');
      setData(await result.json());
      setLastFetch(Date.now());
    } catch (err) {
      setError(err);
    } finally {
      setIsLoading(false);
    }
  };

  // Need to track staleness manually
  const isStale = !lastFetch || Date.now() - lastFetch > 60000;
  if (isStale) fetchData();
}, [lastFetch]);

// With React Query - automatic server state management
const { data, isLoading, error } = useQuery({
  queryKey: ['data'],
  queryFn: () => fetch('/api/data').then(res => res.json()),
  staleTime: 60000
});

React Query automatically handles what you'd need dozens of lines to manage manually: caching, staleness tracking, background refetching, request deduplication, and state synchronization across components.

Using React Query as Global State Management

One of React Query's most powerful features is automatic global state synchronization. When you fetch data with the same queryKey in multiple components, they all share the same cached data and update together:

// UserProfile.jsx
function UserProfile() {
  const { data: user } = useQuery({
    queryKey: ['user', 'current'],
    queryFn: fetchCurrentUser,
  });

  return <div>{user.name}</div>;
}

// UserAvatar.jsx (different component, same data!)
function UserAvatar() {
  const { data: user } = useQuery({
    queryKey: ['user', 'current'],
    queryFn: fetchCurrentUser,
  });

  return <img src={user.avatar} />;
}

// Settings.jsx (updates everywhere automatically)
function Settings() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: updateUserName,
    onSuccess: (updatedUser) => {
      // This updates BOTH UserProfile and UserAvatar instantly
      queryClient.setQueryData(['user', 'current'], updatedUser);
    }
  });

  return <button onClick={() => mutation.mutate({ name: 'New Name' })}>
    Update Name
  </button>;
}

What just happened?

  1. Multiple components use the same queryKey: ['user', 'current']

  2. React Query fetches once and shares the result

  3. When we update the cache with setQueryData, all components re-render with new data

  4. No props drilling, no context providers, no manual subscriptions

This replaces traditional global state patterns:

// Traditional approach - lots of boilerplate
const UserContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    fetchUser().then(setUser);
  }, []);

  return (
    <UserContext.Provider value={{ user, setUser, loading }}>
      {children}
    </UserContext.Provider>
  );
}

// React Query approach - automatic
// Just use the same queryKey wherever you need the data!

The queryKey acts as a global identifier. Any component using that key gets the same data, loading states, and updates—automatically.