Skip to content
All essays
WebMarch 25, 202430 min

React Query Data Fetching Guide

Master React Query: server state management, caching, mutations, and best practices

Ü
Ümit Uz
Mobile & Full Stack Developer

React Query (TanStack Query) revolutionizes server state management in React. Let's master it from setup to advanced patterns.

Why React Query?

React Query solves:

  • Caching: Automatic caching and revalidation
  • Background updates: Keep data fresh
  • Deduplication: Avoid duplicate requests
  • Pagination: Built-in pagination support
  • Optimistic updates: Smooth UI updates
  • Infinite scrolling: Easy implementation
  • DevTools: Debugging made easy

Installation

bash
npm install @tanstack/react-query

Basic Setup

Configure Provider

typescript
// index.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

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

ReactDOM.createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Queries

Basic Query

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

function Users() {
  const { data, isLoading, error, isError } = useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const response = await fetch('https://api.example.com/users');
      if (!response.ok) {
        throw new Error('Failed to fetch users');
      }
      return response.json();
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (isError) return <div>Error: {error.message}</div>;

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

Query with Parameters

typescript
function User({ userId }: { userId: number }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`https://api.example.com/users/${userId}`);
      return response.json();
    },
    enabled: !!userId, // Only run if userId exists
  });

  if (isLoading) return <div>Loading...</div>;
  if (!data) return null;

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

Dependent Queries

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

  // Second query: get posts (only if user exists)
  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!user, // Dependent on first query
  });

  if (!user || !posts) return <div>Loading...</div>;

  return (
    <div>
      <h1>{user.name}'s Posts</h1>
      {posts.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  );
}

Pagination

Basic Pagination

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

  const { data, isLoading } = useQuery({
    queryKey: ['users', page],
    queryFn: () => fetchUsers(page),
  });

  return (
    <div>
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <>
          <ul>
            {data?.users.map((user) => (
              <li key={user.id}>{user.name}</li>
            ))}
          </ul>
          <button
            onClick={() => setPage((p) => Math.max(1, p - 1))}
            disabled={page === 1}
          >
            Previous
          </button>
          <span>Page {page}</span>
          <button
            onClick={() => setPage((p) => p + 1)}
            disabled={!data?.hasMore}
          >
            Next
          </button>
        </>
      )}
    </div>
  );
}

Infinite Scroll

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

function InfiniteUsers() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['users'],
    queryFn: ({ pageParam = 1 }) => fetchUsers(pageParam),
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.hasMore ? lastPage.nextPage : undefined,
  });

  const observer = useRef<IntersectionObserver>();

  const lastElementRef = useCallback(
    (node) => {
      if (isFetchingNextPage) return;
      if (observer.current) observer.current.disconnect();

      observer.current = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting && hasNextPage) {
          fetchNextPage();
        }
      });

      if (node) observer.current.observe(node);
    },
    [isFetchingNextPage, fetchNextPage, hasNextPage]
  );

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      {data?.pages.map((page) => (
        <div key={page.page}>
          {page.users.map((user) => (
            <div key={user.id}>{user.name}</div>
          ))}
        </div>
      ))}
      <div ref={lastElementRef}>
        {isFetchingNextPage ? 'Loading more...' : hasNextPage ? 'Load more' : 'Nothing more to load'}
      </div>
    </div>
  );
}

Mutations

Basic Mutation

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

function CreateUser() {
  const queryClient = useQueryClient();
  const [name, setName] = useState('');

  const mutation = useMutation({
    mutationFn: (newUser: { name: string }) =>
      fetch('https://api.example.com/users', {
        method: 'POST',
        body: JSON.stringify(newUser),
      }).then((res) => res.json()),

    onSuccess: (data) => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['users'] });
      console.log('User created:', data);
    },

    onError: (error) => {
      console.error('Error creating user:', error);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({ name });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="User name"
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      {mutation.isError && <div>Error: {mutation.error.message}</div>}
    </form>
  );
}

Optimistic Updates

typescript
function UpdateUser({ userId }: { userId: number }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (updates: Partial<User>) =>
      fetch(`https://api.example.com/users/${userId}`, {
        method: 'PUT',
        body: JSON.stringify(updates),
      }).then((res) => res.json()),

    onMutate: async (newData) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['user', userId] });

      // Snapshot previous value
      const previousUser = queryClient.getQueryData(['user', userId]);

      // Optimistically update to new value
      queryClient.setQueryData(['user', userId], (old: User) => ({
        ...old,
        ...newData,
      }));

      // Return context with previous value
      return { previousUser };
    },

    onError: (err, newData, context) => {
      // Rollback to previous value
      queryClient.setQueryData(['user', userId'], context?.previousUser);
    },

    onSettled: () => {
      // Refetch to ensure server state
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });
}

Cache Management

Manual Cache Updates

typescript
function AddToCart({ productId }: { productId: number }) {
  const queryClient = useQueryClient();

  const addToCart = () => {
    // Update cart cache
    queryClient.setQueryData(['cart'], (old: Cart) => ({
      ...old,
      items: [...old.items, productId],
    }));
  };

  return <button onClick={addToCart}>Add to Cart</button>;
}

Invalidate Queries

typescript
// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['users'] });

// Invalidate all queries starting with 'users'
queryClient.invalidateQueries({ queryKey: ['users'] });

// Invalidate multiple queries
queryClient.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'users',
});

Reset Queries

typescript
// Reset specific query
queryClient.resetQueries({ queryKey: ['users'] });

// Reset all queries
queryClient.resetQueries();

Remove Queries

typescript
// Remove specific query
queryClient.removeQueries({ queryKey: ['users'] });

// Remove inactive queries
queryClient.removeQueries({ inactive: true });

Advanced Patterns

Parallel Queries

typescript
function Dashboard() {
  // Run queries in parallel
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
  const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
  const statsQuery = useQuery({ queryKey: ['stats'], queryFn: fetchStats });

  if (usersQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1>{usersQuery.data?.length} Users</h1>
      <h1>{postsQuery.data?.length} Posts</h1>
      <h1>{statsQuery.data?.views} Views</h1>
    </div>
  );
}

Dynamic Query Keys

typescript
// Query key factory
const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: string) => [...userKeys.lists(), { filters }] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: number) => [...userKeys.details(), id] as const,
};

// Usage
useQuery({
  queryKey: userKeys.list('active'),
  queryFn: () => fetchUsers('active'),
});

// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: userKeys.all });

// Invalidate only user lists
queryClient.invalidateQueries({ queryKey: userKeys.lists() });

Custom Hooks

typescript
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

export function useUsers(filters?: string) {
  return useQuery({
    queryKey: ['users', filters],
    queryFn: () => fetchUsers(filters),
    staleTime: 1000 * 60 * 5, // 5 minutes
  });
}

export function useUser(id: number) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
    enabled: !!id,
  });
}

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

  return useMutation({
    mutationFn: (data: CreateUserInput) => createUser(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

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

  return useMutation({
    mutationFn: ({ id, data }: { id: number; data: UpdateUserInput }) =>
      updateUser(id, data),
    onMutate: async ({ id, data }) => {
      await queryClient.cancelQueries({ queryKey: ['user', id] });
      const previousUser = queryClient.getQueryData(['user', id]);
      queryClient.setQueryData(['user', id'], (old: User) => ({
        ...old,
        ...data,
      }));
      return { previousUser };
    },
    onError: (err, variables, context) => {
      queryClient.setQueryData(['user', variables.id], context?.previousUser);
    },
    onSettled: (_, __, { id }) => {
      queryClient.invalidateQueries({ queryKey: ['user', id] });
    },
  });
}

// Usage in components
function UserList() {
  const { data, isLoading } = useUsers();
  const createUser = useCreateUser();

  // ...
}

Error Handling

Retry Configuration

typescript
useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
  retry: 3, // Retry 3 times on failure
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), // Exponential backoff
});

Error Boundaries

typescript
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
  return (
    <div>
      <h1>Something went wrong</h1>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Users />
    </ErrorBoundary>
  );
}

Performance Optimization

Selectors

typescript
// Select specific data to reduce re-renders
const userName = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  select: (data) => data.name, // Only name
});

Cache Time Configuration

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

Prefetching

typescript
// Prefetch data on hover
function UserLink({ userId }: { userId: number }) {
  const queryClient = useQueryClient();

  const prefetchUser = () => {
    queryClient.prefetchQuery({
      queryKey: ['user', userId],
      queryFn: () => fetchUser(userId),
    });
  };

  return (
    <a href={`/user/${userId}`} onMouseEnter={prefetchUser}>
      View User
    </a>
  );
}

DevTools

Setup DevTools

typescript
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Best Practices

1. Organize Query Keys

typescript
// Query key factory pattern
const queryKeys = {
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (filters: string) => [...queryKeys.users.lists(), { filters }] as const,
    details: () => [...queryKeys.users.all, 'detail'] as const,
    detail: (id: number) => [...queryKeys.users.details(), id] as const,
  },
};

2. Create Custom Hooks

typescript
// Reusable query hooks
export const useUsers = (filters?: string) =>
  useQuery({
    queryKey: queryKeys.users.list(filters),
    queryFn: () => fetchUsers(filters),
  });

3. Handle Loading States

typescript
const { isLoading, isFetching, data } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});

// isLoading: First load
// isFetching: Background refetch
// data: Cached data available

4. Use Suspense

typescript
// Enable Suspense mode
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
    },
  },
});

// Use with Suspense
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Suspense fallback={<div>Loading...</div>}>
        <Users />
      </Suspense>
    </QueryClientProvider>
  );
}

5. Mutate with Proper Error Handling

typescript
const mutation = useMutation({
  mutationFn: createPost,
  onSuccess: () => {
    toast.success('Post created!');
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
  onError: (error) => {
    toast.error(`Error: ${error.message}`);
  },
});

Conclusion

React Query transforms server state management. Use it for:

  • API data fetching
  • Caching and synchronization
  • Background updates
  • Optimistic updates

Master queries, mutations, and infinite scrolling for powerful data fetching. The result is fast, responsive UIs with minimal boilerplate.

Related essays

Next essay
React Hook Form Complete Guide