Skip to content
All posts
Tutorial4 min read

Stop Writing API Wrappers — Use TanStack Query Instead

DebuggerMe TeamDebuggerMe TeamApril 14, 2026
Clean code on a dark terminal screen
Photo by Unsplash
On this page

The typical React data-fetching pattern looks something like this:

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

TanStack Query eliminates all of these. 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>
    );
}

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>
    );
}

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.

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
DebuggerMe Team

Written by

DebuggerMe Team

The DebuggerMe team builds developer tools, writes technical content, and helps teams ship better software.

Share this post

Back to all posts

Related Articles

All articles →