Skip to content
All posts
Tutorial9 min read

Stop Writing API Wrappers. Use TanStack Query Instead

J
Jamith Nimantha
April 14, 2026
Clean code on a dark terminal screen
Photo by Unsplash
On this page

useEffect plus useState is not a data-fetching solution, it is the raw materials for one. The version everybody writes has no deduplication, no cache, and a race condition that shows up the moment a prop changes twice in quick succession.

TanStack Query is a cache with a React binding attached, and that framing explains most of its API. Here is the pattern it replaces:

tsx
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);
        fetch(`/api/users/${userId}`)
            .then(r => r.json())
            .then(data => { setUser(data); setLoading(false); })
            .catch(err => { setError(err); setLoading(false); });
    }, [userId]);

    if (loading) return <Spinner />;
    if (error) return <Error message={error.message} />;
    return <Profile user={user!} />;
}

This has about 6 silent bugs. Let's talk about them.

What's Wrong With The Pattern Above

  1. No deduplication: two components mounting simultaneously fire two identical requests
  2. No caching: navigating away and back refetches every time
  3. No background refresh: data goes stale silently
  4. Race conditions: if userId changes quickly, responses can resolve out of order
  5. No retry: a single network blip shows an error forever
  6. No loading state persistence: going back to a list shows a spinner even when data is fresh

Number four is the one worth dwelling on, because it is the hardest to reproduce and the easiest to ship. If userId changes from 1 to 2, two requests are in flight. Nothing guarantees they resolve in order. If the response for 1 arrives second, it calls setUser last and the profile for user 1 renders under a page that says user 2.

The fix in raw useEffect is an ignore flag in the cleanup function:

tsx
useEffect(() => {
  let ignore = false;
  fetch(`/api/users/${userId}`)
    .then(r => r.json())
    .then(data => { if (!ignore) setUser(data); });
  return () => { ignore = true; };
}, [userId]);

Every hand-rolled fetch in your codebase needs that, and most do not have it.

TanStack Query eliminates all six. Here's the same component:

tsx
function UserProfile({ userId }: { userId: string }) {
    const { data: user, isLoading, error } = useQuery({
        queryKey: ['user', userId],
        queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    });

    if (isLoading) return <Spinner />;
    if (error) return <Error message={error.message} />;
    return <Profile user={user} />;
}

12 lines → 7 lines, and now it has: deduplication, caching, background refetching, race condition safety, automatic retries, and stale-while-revalidate.

Setup

bash
npm install @tanstack/react-query

Wrap your app:

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

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

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

staleTime and gcTime are the two settings that matter

Almost every complaint about TanStack Query refetching too much or too little comes down to these two, and they are routinely confused because both are durations measured from the same moment.

  • staleTime is how long data is considered fresh. While fresh, mounting another component with the same key serves the cache and fires no request. Default: 0, meaning data is stale immediately.
  • gcTime is how long an unused query stays in memory after its last observer unmounts. Default: five minutes.

The default staleTime: 0 is what surprises people. It does not mean "refetch constantly", it means "refetch on the next trigger", and the triggers are component mount, window refocus, and network reconnect. Navigating back to a list therefore refetches, which reads as excessive on a dashboard and is exactly right on a trading screen.

tsx
const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 5 * 60 * 1000,   // fresh for five minutes, no refetch on mount
  gcTime: 30 * 60 * 1000,     // keep it cached half an hour after unmount
});

A rule that holds up: set staleTime to how long you are willing to show a stale value, and leave gcTime alone unless memory is a problem. Reference data such as a country list can sit at Infinity.

[!NOTE] gcTime was called cacheTime before v5. Older answers on Stack Overflow use the old name, which is a common source of confusion when the option appears to do nothing.

Mutations

For writes, use useMutation:

tsx
function CreatePostForm() {
    const queryClient = useQueryClient();

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

        onSuccess: () => {
            // Invalidate the posts list so it refetches
            queryClient.invalidateQueries({ queryKey: ['posts'] });
        },
    });

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

Optimistic Updates

This is where TanStack Query really shines:

tsx
const { mutate } = useMutation({
    mutationFn: toggleLike,

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

        // Snapshot current value
        const previous = queryClient.getQueryData(['post', postId]);

        // Optimistically update
        queryClient.setQueryData(['post', postId], (old: Post) => ({
            ...old,
            liked: !old.liked,
            likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
        }));

        return { previous };
    },

    onError: (err, { postId }, context) => {
        // Roll back on error
        queryClient.setQueryData(['post', postId], context?.previous);
    },
});

The UI updates instantly on click. If the server call fails, it rolls back automatically.

Structuring Your Query Keys

Use a factory pattern to keep keys consistent:

typescript
const userQueries = {
    all: () => ['users'] as const,
    lists: () => [...userQueries.all(), 'list'] as const,
    detail: (id: string) => [...userQueries.all(), 'detail', id] as const,
    posts: (userId: string) => [...userQueries.detail(userId), 'posts'] as const,
};

// Usage
useQuery({ queryKey: userQueries.detail(userId), queryFn: fetchUser });
useQuery({ queryKey: userQueries.posts(userId), queryFn: fetchUserPosts });

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

// Invalidate just one user's data
queryClient.invalidateQueries({ queryKey: userQueries.detail(userId) });

Prefetching for Perceived Performance

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

    return (
        <Link
            href={`/users/${user.id}`}
            onMouseEnter={() => {
                // Prefetch on hover - data is ready before they click
                queryClient.prefetchQuery({
                    queryKey: userQueries.detail(user.id),
                    queryFn: () => fetchUser(user.id),
                });
            }}
        >
            {user.name}
        </Link>
    );
}

Errors, retries, and the thing fetch gets wrong

One detail catches everyone migrating from raw fetch: fetch does not reject on HTTP error statuses. A 404 or a 500 resolves normally with ok: false, so a queryFn written as fetch(url).then(r => r.json()) reports success and hands your component a parsed error body.

The queryFn has to throw for the query to enter its error state:

tsx
const queryFn = async () => {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) throw new Error(`Request failed: ${res.status}`);
  return res.json();
};

Retries then behave sensibly. TanStack Query retries three times by default with exponential backoff, which is right for a network blip and wrong for a 404, since no number of retries will conjure the record. Make it conditional:

tsx
useQuery({
  queryKey: ['user', userId],
  queryFn,
  retry: (failureCount, error) => {
    if (error instanceof HttpError && error.status >= 400 && error.status < 500) {
      return false;        // client errors are not transient
    }
    return failureCount < 3;
  },
});

Throwing a typed error rather than a bare Error is what makes this readable, and it is worth the extra class.

isLoading versus isFetching versus isPending

Three flags, and picking the wrong one produces a spinner that flashes on every background refetch.

FlagTrue when
isPendingthere is no data yet, the genuine first load
isFetchinga request is in flight, including background refetches
isLoadingisPending && isFetching, so a first load actively fetching

Use isPending for the full-page skeleton. Use isFetching for a subtle indicator such as a spinner in the corner, so that a stale-while-revalidate refresh does not blank out content the user is reading. Rendering a skeleton on isFetching throws away the main benefit of having a cache.

When TanStack Query Is Overkill

For truly simple, one-off fetches that run once and never change, a plain fetch in a Server Component is simpler. TanStack Query's value compounds in client-heavy apps with: shared data between many components, frequent mutations, real-time feel requirements, or complex cache invalidation needs.

If your app is primarily server-rendered Next.js pages with occasional interactivity, lean on Server Components + fetch with revalidate, and reach for TanStack Query only for the interactive client-side pieces.

The two are not mutually exclusive, and the combination is often the right answer: fetch on the server, hand the result to the client as initial data, and let TanStack Query own it from there. That way the first paint has no spinner and subsequent interactions get caching and background refresh.

tsx
// Server Component
const user = await fetchUser(userId);
return <UserProfile userId={userId} initialUser={user} />;
tsx
// Client Component
useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  initialData: initialUser,
});

The distinction worth keeping straight: initialData is written into the cache and treated as real, so staleTime applies to it. placeholderData is displayed but never cached, so a fetch fires immediately. Use initialData for server-rendered content you trust, and placeholderData for a skeleton value you do not.

One decision rule for the whole article: if the data is read once per page load and never mutated, a Server Component and fetch is less machinery and you should use it. The moment two components need the same data, or a mutation has to invalidate it, a cache earns its place, and writing that cache yourself is how the six bugs at the top of this post get into a codebase.

Tools in this post

Related Tool

JSON Parser & Formatter

Validate, format, and minify JSON data with error highlighting.

Try it free

Related Tool

XML Formatter & Beautifier

Beautify and minify XML code with syntax highlighting.

Try it free
J

Written by

Jamith Nimantha

Software developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.

Share this post

Back to all posts

Related Articles

All articles →