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.

Enjoyed this essay? Share it.

Related essays

Next essay
React Hook Form Complete Guide