JavaScript 8 min read

TanStack Query: Server State Management for React

Master TanStack Query (React Query) for data fetching. Learn queries, mutations, caching, pagination, optimistic updates, and real-world patterns.

MR

Moshiour Rahman

Advertisement

What is TanStack Query?

TanStack Query (formerly React Query) is a powerful library for managing server state in React applications. It handles data fetching, caching, synchronization, and updates with minimal configuration.

Why TanStack Query?

ProblemSolution
Manual loading statesAutomatic state management
Duplicate requestsRequest deduplication
Stale dataSmart caching and refetching
Complex cache invalidationSimple cache management
Optimistic updatesBuilt-in support

Getting Started

Installation

npm install @tanstack/react-query

Setup Provider

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

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

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

Basic Queries

Simple Query

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

function fetchUsers() {
  return fetch('/api/users').then((res) => res.json());
}

function UserList() {
  const { data, isLoading, isError, error, refetch } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers
  });

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

  return (
    <div>
      <button onClick={() => refetch()}>Refresh</button>
      <ul>
        {data.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

Query with Parameters

function fetchUser(userId: string) {
  return fetch(`/api/users/${userId}`).then((res) => res.json());
}

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    enabled: !!userId // Only fetch when userId exists
  });

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

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

Query with Search Params

interface SearchParams {
  query: string;
  page: number;
  limit: number;
}

function searchUsers(params: SearchParams) {
  const searchParams = new URLSearchParams({
    q: params.query,
    page: String(params.page),
    limit: String(params.limit)
  });
  return fetch(`/api/users/search?${searchParams}`).then((res) => res.json());
}

function UserSearch() {
  const [search, setSearch] = useState({ query: '', page: 1, limit: 10 });

  const { data, isFetching } = useQuery({
    queryKey: ['users', 'search', search],
    queryFn: () => searchUsers(search),
    placeholderData: (previousData) => previousData, // Keep previous data while fetching
  });

  return (
    <div>
      <input
        value={search.query}
        onChange={(e) => setSearch({ ...search, query: e.target.value, page: 1 })}
        placeholder="Search users..."
      />
      {isFetching && <span>Searching...</span>}
      {/* Results */}
    </div>
  );
}

Mutations

Basic Mutation

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

function createUser(userData: { name: string; email: string }) {
  return fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userData)
  }).then((res) => res.json());
}

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

  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
    onError: (error) => {
      console.error('Error creating user:', error);
    }
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    mutation.mutate({ name: 'John', email: 'john@example.com' });
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* Form fields */}
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {mutation.isSuccess && <p>User created!</p>}
    </form>
  );
}

Mutation with Variables

interface UpdateUserData {
  id: string;
  name?: string;
  email?: string;
}

function updateUser({ id, ...data }: UpdateUserData) {
  return fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  }).then((res) => res.json());
}

function UserEditForm({ user }: { user: User }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: updateUser,
    onSuccess: (data) => {
      // Update specific query
      queryClient.setQueryData(['user', user.id], data);
      // Invalidate list
      queryClient.invalidateQueries({ queryKey: ['users'] });
    }
  });

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

Optimistic Updates

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

  return useMutation({
    mutationFn: updateTodo,
    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: Todo[]) =>
        old.map((todo) =>
          todo.id === newTodo.id ? { ...todo, ...newTodo } : todo
        )
      );

      // Return context with snapshot
      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      // Rollback on error
      queryClient.setQueryData(['todos'], context?.previousTodos);
    },
    onSettled: () => {
      // Refetch after mutation
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });
}

Pagination

Basic Pagination

function fetchProjects(page: number) {
  return fetch(`/api/projects?page=${page}&limit=10`).then((res) => res.json());
}

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

  const { data, isLoading, isFetching, isPlaceholderData } = useQuery({
    queryKey: ['projects', page],
    queryFn: () => fetchProjects(page),
    placeholderData: (previousData) => previousData
  });

  return (
    <div>
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <>
          <ul style={{ opacity: isFetching ? 0.5 : 1 }}>
            {data.projects.map((project) => (
              <li key={project.id}>{project.name}</li>
            ))}
          </ul>

          <div>
            <button
              onClick={() => setPage((p) => Math.max(p - 1, 1))}
              disabled={page === 1}
            >
              Previous
            </button>
            <span>Page {page}</span>
            <button
              onClick={() => setPage((p) => p + 1)}
              disabled={isPlaceholderData || !data.hasMore}
            >
              Next
            </button>
          </div>
        </>
      )}
    </div>
  );
}

Infinite Scroll

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

function fetchPosts({ pageParam = 1 }) {
  return fetch(`/api/posts?page=${pageParam}&limit=20`).then((res) => res.json());
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: 1,
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    }
  });

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

  return (
    <div>
      {data.pages.map((page, i) => (
        <React.Fragment key={i}>
          {page.posts.map((post) => (
            <PostCard key={post.id} post={post} />
          ))}
        </React.Fragment>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? 'Loading more...'
          : hasNextPage
          ? 'Load More'
          : 'No more posts'}
      </button>
    </div>
  );
}

Advanced Patterns

Dependent Queries

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

  // Second query - depends on first
  const { data: posts } = useQuery({
    queryKey: ['posts', user?.id],
    queryFn: () => fetchUserPosts(user!.id),
    enabled: !!user // Only runs when user is available
  });

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

Parallel Queries

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

function Dashboard({ userIds }: { userIds: string[] }) {
  const results = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id)
    }))
  });

  const isLoading = results.some((r) => r.isLoading);
  const users = results.map((r) => r.data).filter(Boolean);

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

  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Prefetching

function UserList() {
  const queryClient = useQueryClient();
  const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });

  // Prefetch on hover
  const prefetchUser = (userId: string) => {
    queryClient.prefetchQuery({
      queryKey: ['user', userId],
      queryFn: () => fetchUser(userId),
      staleTime: 1000 * 60 * 5
    });
  };

  return (
    <ul>
      {users?.map((user) => (
        <li
          key={user.id}
          onMouseEnter={() => prefetchUser(user.id)}
        >
          <Link to={`/users/${user.id}`}>{user.name}</Link>
        </li>
      ))}
    </ul>
  );
}

Initial Data

// From cache
function UserProfile({ userId }: { userId: string }) {
  const { data } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    initialData: () => {
      // Get from users list cache
      const users = queryClient.getQueryData(['users']);
      return users?.find((u) => u.id === userId);
    },
    initialDataUpdatedAt: () => {
      // When was parent query updated?
      return queryClient.getQueryState(['users'])?.dataUpdatedAt;
    }
  });
}

// From props
function UserDetail({ user }: { user: User }) {
  const { data } = useQuery({
    queryKey: ['user', user.id],
    queryFn: () => fetchUser(user.id),
    initialData: user
  });
}

Cache Management

Manual Cache Updates

const queryClient = useQueryClient();

// Get cached data
const users = queryClient.getQueryData(['users']);

// Set cached data
queryClient.setQueryData(['users'], (old) => [...old, newUser]);

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

// Remove from cache
queryClient.removeQueries({ queryKey: ['users'] });

// Reset to initial state
queryClient.resetQueries({ queryKey: ['users'] });

// Cancel ongoing queries
queryClient.cancelQueries({ queryKey: ['users'] });

Query Filters

// Invalidate all user-related queries
queryClient.invalidateQueries({ queryKey: ['users'] });

// Invalidate exact query
queryClient.invalidateQueries({
  queryKey: ['users', 'list'],
  exact: true
});

// Invalidate by predicate
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'users' && query.state.data?.length > 0
});

Custom Hooks

useUser Hook

function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    enabled: !!userId,
    staleTime: 1000 * 60 * 5
  });
}

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

  return useMutation({
    mutationFn: updateUser,
    onSuccess: (data) => {
      queryClient.setQueryData(['user', data.id], data);
      queryClient.invalidateQueries({ queryKey: ['users'] });
    }
  });
}

// Usage
function Profile({ userId }) {
  const { data: user, isLoading } = useUser(userId);
  const { mutate: updateUser, isPending } = useUpdateUser();

  // ...
}

Summary

FeatureHook
Fetch datauseQuery
Modify datauseMutation
Multiple queriesuseQueries
Infinite scrolluseInfiniteQuery
Access clientuseQueryClient
SuspenseuseSuspenseQuery

TanStack Query simplifies data fetching with automatic caching, background updates, and powerful developer tools.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.