mrkeyoor.com_
Wed 05 Aug 05:01 UTC
npmWeb Frontendupdated 05 Aug 2026

@tanstack/react-query

TanStack Query's React bindings manage server state: the data you fetch over the network but do not own. useQuery gives you caching, request deduplication, background refetching, and staleness tracking keyed by a query key; useMutation handles writes plus cache invalidation. It replaces hand-rolled useEffect fetching and most of the store code teams wrote just to hold API responses. It is protocol agnostic, so it works with REST, GraphQL, or anything that returns a promise.

Verdict

If a React app talks to an API without a framework-owned data layer, this is the default choice and deserves to be. Budget real time for query key discipline, and do not bolt it onto a framework that already caches for you.

API stability4/5v5 has been the current major since late 2023 and minors ship weekly without breakage; the v4 to v5 jump was a genuine rename-everything migration though.
Docs5/5tanstack.com/query has guides, full API reference, framework-specific docs, and an official v5 migration guide; examples exist for nearly every pattern.
Maintenance5/5Pushed the day before this review, 50k stars, active core team plus paid partners; 209 open issues and PRs is modest for its usage.
Ecosystem5/5Official devtools, a shared query-core powering React, Vue, Solid, Svelte, and Angular adapters, and years of community answers for every edge case.

Use it if

  • Multiple components fetch the same data and you keep rebuilding loading, error, and refetch logic by hand
  • You need caching, deduplication, and background refetch without adopting an entire data framework
  • You talk to REST or any promise-returning API and do not want to be pushed toward GraphQL
  • You want pagination, infinite scroll, and optimistic updates as documented patterns instead of bespoke code
Skip it if

Setup reality

Install is easy: one package with a single react peer dependency, wrap the app in QueryClientProvider, done. The cost shows up later. The default staleTime of 0 refetches on every window focus, so you tune defaults early or watch the network tab churn. You need a query key convention the whole team actually follows. SSR requires prefetch plus dehydrate plus HydrationBoundary wiring per route. And the v4 to v5 migration renamed a lot (cacheTime to gcTime, status loading to pending, object-only signatures), so pre-2024 blog posts and old Stack Overflow answers actively mislead.

Patterns

Fetch and cache data with useQuerybasic-query

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

function Todos() {
  const { data, isPending, error } = useQuery({
    queryKey: ['todos'],
    queryFn: () => fetch('/api/todos').then((r) => r.json()),
  });

  if (isPending) return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;
  return <ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}

v5 only accepts the object form, and v4's isLoading is now isPending; fetch does not reject on 404/500, so throw on !res.ok yourself.

Create the client with sane defaultsprovider-setup

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

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { staleTime: 60 * 1000, retry: 1 },
  },
});

export function App({ children }) {
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

The default staleTime is 0, meaning a refetch on every mount and window focus; set a real value unless you want that.

Write data, then invalidate the cachemutation-invalidate

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

const queryClient = useQueryClient();
const mutation = useMutation({
  mutationFn: (newTodo) =>
    fetch('/api/todos', { method: 'POST', body: JSON.stringify(newTodo) }).then((r) => r.json()),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});

mutation.mutate({ title: 'buy milk' });

invalidateQueries marks matching queries stale and refetches the active ones; it matches key prefixes, so ['todos'] also hits ['todos', 5].

Run a query only when its input existsdependent-query

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

const userId = user?.id;

const { data: projects } = useQuery({
  queryKey: ['projects', userId],
  queryFn: () => getProjectsByUser(userId),
  enabled: !!userId,
});

A disabled query reports status pending with fetchStatus idle, so do not treat isPending alone as a spinner signal.

Load pages with useInfiniteQueryinfinite-scroll

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

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: ({ pageParam }) => fetch(`/api/projects?cursor=${pageParam}`).then((r) => r.json()),
  initialPageParam: 0,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
});

const items = data?.pages.flatMap((p) => p.items) ?? [];

initialPageParam is required in v5; returning undefined (not null) from getNextPageParam is what sets hasNextPage to false.

Keep old data visible while a new page loadspaginated-query

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

const { data, isPlaceholderData } = useQuery({
  queryKey: ['todos', page],
  queryFn: () => fetchTodos(page),
  placeholderData: keepPreviousData,
});

The v4 keepPreviousData boolean option is gone in v5; import the keepPreviousData function and pass it to placeholderData.

Update the UI before the server confirmsoptimistic-update

useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    await queryClient.cancelQueries({ queryKey: ['todos'] });
    const previous = queryClient.getQueryData(['todos']);
    queryClient.setQueryData(['todos'], (old) =>
      old.map((t) => (t.id === newTodo.id ? newTodo : t))
    );
    return { previous };
  },
  onError: (err, newTodo, context) => {
    queryClient.setQueryData(['todos'], context.previous);
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
});

cancelQueries matters: without it an in-flight refetch can land after your setQueryData and overwrite the optimistic value.

Prefetch on the server and hydrate on the clientssr-hydration

import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query';

export default async function Page() {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({ queryKey: ['todos'], queryFn: getTodos });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Todos />
    </HydrationBoundary>
  );
}

Create a fresh QueryClient per request on the server or you leak data between users; v4's Hydrate component is now HydrationBoundary.

Define a query once with queryOptionsshared-query-options

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

export const todoOptions = (id) =>
  queryOptions({
    queryKey: ['todos', id],
    queryFn: () => fetchTodo(id),
    staleTime: 5 * 60 * 1000,
  });

const { data } = useQuery(todoOptions(5));
// also works: queryClient.prefetchQuery(todoOptions(5))

queryOptions keeps key, fetcher, and types in one place, so useQuery, prefetchQuery, and getQueryData stop drifting apart.

Use Suspense instead of isPending checkssuspense-query

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

function Todos() {
  const { data } = useSuspenseQuery({ queryKey: ['todos'], queryFn: fetchTodos });
  return <List items={data} />;
}
// <Suspense fallback={<Spinner />}><Todos /></Suspense>

data is never undefined here, but you must provide both a Suspense boundary and an error boundary or failures bubble to the root.

Refetch on an intervalpolling

useQuery({
  queryKey: ['job', jobId],
  queryFn: () => fetchJobStatus(jobId),
  refetchInterval: (query) =>
    query.state.data?.status === 'done' ? false : 2000,
});

Returning false from the refetchInterval function stops polling; by default polling pauses when the tab is backgrounded unless refetchIntervalInBackground is set.

Alternatives

PackageRegistryPick it when
swrnpmYou want the same stale-while-revalidate idea with a smaller API surface and you are already in the Vercel ecosystem.
@apollo/clientnpmYou are all-in on GraphQL and want a normalized cache where entities are shared across queries.
@reduxjs/toolkitnpmYou already run Redux and want data fetching (RTK Query) inside the store you have instead of a second cache.