← All articles
REACT TanStack Query: Server State Management for React (a... 2026-03-04 · 5 min read · tanstack query · react query · react

TanStack Query: Server State Management for React (and Beyond)

React 2026-03-04 · 5 min read tanstack query react query react data fetching caching server state hooks typescript

Most React state management debates are actually about the wrong problem. Libraries like Redux and Zustand manage client state well — UI toggles, form values, selected items. But the biggest challenge in most applications is server state: data that lives on a remote server and needs to be fetched, cached, synchronized, and updated.

TanStack Query (formerly React Query) is built specifically for server state. It handles caching, background refetching, loading/error states, pagination, optimistic updates, and cache invalidation — the things that get messy when you try to manage server data with useState and useEffect.

The Problem TanStack Query Solves

Without TanStack Query, fetching data in React typically looks like:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <Error />;
  return <div>{user?.name}</div>;
}

Issues with this approach:

TanStack Query's version:

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

  if (isLoading) return <Spinner />;
  if (error) return <Error />;
  return <div>{user?.name}</div>;
}

The library handles caching, deduplication, background refetching, and race conditions automatically.

Installation

npm install @tanstack/react-query
# Optional dev tools
npm install @tanstack/react-query-devtools

Setup

Wrap your app with QueryClientProvider:

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 * 10,    // 10 minutes (was cacheTime in v4)
    },
  },
});

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

The devtools (development only) show cached queries, their state, and data — invaluable for debugging.

Queries

useQuery fetches and caches data:

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

function PostList() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const response = await fetch('/api/posts');
      if (!response.ok) throw new Error('Network error');
      return response.json() as Promise<Post[]>;
    },
  });

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

  return (
    <ul>
      {data.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Query Keys

The queryKey identifies cache entries. It's used to:

Keys should be serializable and describe the data:

// Simple key
queryKey: ['posts']

// Key with parameters — different cache entry per ID
queryKey: ['post', postId]

// Key with filters
queryKey: ['posts', { status: 'published', page: 2 }]

Dependent Queries

Wait for one query's result before fetching another:

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

const { data: posts } = useQuery({
  queryKey: ['posts', user?.id],
  queryFn: () => fetchUserPosts(user!.id),
  enabled: !!user,  // only runs when user is available
});

Mutations

useMutation handles create/update/delete operations:

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

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

  const mutation = useMutation({
    mutationFn: (newPost: CreatePostInput) =>
      fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(newPost),
      }).then(r => r.json()),

    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },

    onError: (error) => {
      console.error('Failed to create post:', error);
    },
  });

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      mutation.mutate({ title: 'New Post', content: '...' });
    }}>
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  );
}

Optimistic Updates

Update the cache immediately, then revert if the mutation fails:

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (updatedTodo) => {
    // Cancel any in-flight queries
    await queryClient.cancelQueries({ queryKey: ['todos', updatedTodo.id] });

    // Snapshot previous value
    const previousTodo = queryClient.getQueryData(['todos', updatedTodo.id]);

    // Optimistically update
    queryClient.setQueryData(['todos', updatedTodo.id], updatedTodo);

    // Return context with previous value
    return { previousTodo };
  },
  onError: (err, updatedTodo, context) => {
    // Revert on error
    queryClient.setQueryData(
      ['todos', updatedTodo.id],
      context?.previousTodo
    );
  },
  onSettled: (updatedTodo) => {
    // Refetch after success or error
    queryClient.invalidateQueries({ queryKey: ['todos', updatedTodo.id] });
  },
});

Pagination

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

  const { data, isPlaceholderData } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetchPosts(page),
    placeholderData: keepPreviousData,  // show previous page while next loads
  });

  return (
    <>
      {data?.posts.map(post => <Post key={post.id} post={post} />)}
      <button
        onClick={() => setPage(p => p - 1)}
        disabled={page === 1}
      >
        Previous
      </button>
      <button
        onClick={() => setPage(p => p + 1)}
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next
      </button>
    </>
  );
}

Infinite Queries

For infinite scroll or "load more" patterns:

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

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
    initialPageParam: undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  return (
    <>
      {data?.pages.flatMap(page => page.posts).map(post => (
        <Post key={post.id} post={post} />
      ))}
      {hasNextPage && (
        <button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading more...' : 'Load More'}
        </button>
      )}
    </>
  );
}

Cache and Stale Time

Understanding staleTime and gcTime:

useQuery({
  queryKey: ['config'],
  queryFn: fetchConfig,
  staleTime: Infinity,  // never refetch automatically
});

useQuery({
  queryKey: ['feed'],
  queryFn: fetchFeed,
  staleTime: 30 * 1000,  // 30 seconds fresh
  refetchInterval: 60 * 1000,  // refetch every 60 seconds when in view
});

TypeScript Integration

TanStack Query v5 is fully typed:

type Post = {
  id: number;
  title: string;
  content: string;
};

const { data } = useQuery({
  queryKey: ['post', id],
  queryFn: (): Promise<Post> => fetch(`/api/posts/${id}`).then(r => r.json()),
});

// data is typed as Post | undefined
data?.title  // ✓ TypeScript knows the shape

TanStack Query vs. SWR

Both solve the same problem. Key differences:

Feature TanStack Query SWR
Mutations Full support Manual
Optimistic updates Built-in helpers Manual
Infinite queries Built-in useSWRInfinite
Devtools Official Third-party
Bundle size ~13KB gzipped ~4KB gzipped
Config options Extensive Minimal
Frameworks React, Vue, Angular, Solid React primarily

SWR is simpler and smaller. TanStack Query has more features for complex data management. For most applications, TanStack Query's additional APIs (mutations, optimistic updates, devtools) pay for the extra weight.

TanStack Query v5 docs are at tanstack.com/query and the repository is TanStack/query.