Gen-Endpoint
Back to Blog

Mastering TanStack Query (React Query v5) with Free Public REST Endpoints

By Kunal Burangi•Published on November 1, 2026•
React & Architecture
#React Query
#TanStack Query
#React
#TypeScript
#State Management
#REST API

Mastering TanStack Query (React Query v5) with Free Public REST Endpoints

In traditional React development, managing asynchronous server data with plain useEffect and useState hooks inevitably leads to massive boilerplate. Developers find themselves constantly writing manual loading booleans, error catches, race condition guards, and cache synchronization logic across dozens of components.

TanStack Query (formerly React Query) fundamentally transformed the React ecosystem by introducing dedicated server-state management. It treats remote data not as local component state, but as a cached remote snapshot equipped with automatic background refetching, deduplication, garbage collection, and optimistic mutation handling.

In this deep-dive guide, we explore the core mental model of TanStack Query v5, master query key architecture, implement infinite scrolling, build optimistic mutations with automatic rollback, and demonstrate performance optimization using Gen-Endpoint's free public REST APIs.


1. The Core Mental Model: Server State vs. Client State

Before writing query hooks, understanding the distinction between client state and server state is paramount:

  • Client State: Ephemeral UI state owned entirely by the browser (e.g., whether a modal is open, selected tab index, or dark mode toggle). This belongs in React's useState or Zustand.
  • Server State: Remote data stored on an external database (e.g., user profiles, products, comments). It is asynchronous, can be changed by other users out of band, and requires caching, background synchronization, and automatic revalidation. This belongs in TanStack Query.

2. Setting Up QueryClientProvider and Global Defaults

To enable TanStack Query, wrap your application root with the QueryClientProvider and configure production-ready defaults:

// app/providers.tsx
'use client';

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

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60 * 5, // Data remains fresh for 5 minutes (no background refetch)
            gcTime: 1000 * 60 * 15,    // Inactive cache garbage collected after 15 minutes
            retry: 2,                  // Retry failed queries twice before throwing error
            refetchOnWindowFocus: false, // Prevent distracting refetches on tab switch during development
          },
        },
      })
  );

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

3. Mastering Query Keys: The Hierarchical Factory Pattern

In TanStack Query, query keys are arrays of values that uniquely identify a cached query. When a query key changes, TanStack Query automatically refetches the data.

The Golden Rule: Always structure query keys hierarchically from most generic to most specific:

// lib/queryKeys.ts
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: { role?: string; search?: string }) => [...userKeys.lists(), filters] as const,
  infinite: (filters: { role?: string }) => [...userKeys.all, 'infinite', filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
};

Why this pattern pays massive dividends:

  • To invalidate all user queries: queryClient.invalidateQueries({ queryKey: userKeys.all })
  • To invalidate only list tables: queryClient.invalidateQueries({ queryKey: userKeys.lists() })
  • To invalidate a single profile: queryClient.invalidateQueries({ queryKey: userKeys.detail('usr_123') })

4. Fetching Data with Server-Side Filtering

Here is how to fetch and filter users using Gen-Endpoint's Users API:

// components/UserList.tsx
'use client';

import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { userKeys } from '@/lib/queryKeys';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

async function fetchUsers(role?: string): Promise<User[]> {
  const url = new URL('https://gen-endpoint.com/api/users');
  if (role) url.searchParams.set('role', role);

  const res = await fetch(url.toString());
  if (!res.ok) {
    throw new Error('Network response was not ok');
  }
  const data = await res.json();
  return Array.isArray(data) ? data : data.users || [];
}

export function UserList() {
  const [selectedRole, setSelectedRole] = useState<string>('');

  const { data: users, isLoading, isError, error, isFetching } = useQuery({
    queryKey: userKeys.list({ role: selectedRole }),
    queryFn: () => fetchUsers(selectedRole),
  });

  if (isLoading) {
    return <div className="p-8 text-center text-sm">Loading team members...</div>;
  }

  if (isError) {
    return <div className="p-4 text-destructive text-sm">Error: {error.message}</div>;
  }

  return (
    <div className="p-6 space-y-4 max-w-2xl mx-auto">
      <div className="flex items-center justify-between">
        <h2 className="text-xl font-bold tracking-tight">Team Directory {isFetching && '🔄'}</h2>
        <select
          value={selectedRole}
          onChange={(e) => setSelectedRole(e.target.value)}
          className="border rounded px-3 py-1 text-sm bg-background"
        >
          <option value="">All Roles</option>
          <option value="admin">Admins</option>
          <option value="editor">Editors</option>
          <option value="viewer">Viewers</option>
        </select>
      </div>

      <ul className="divide-y border rounded-xl bg-card overflow-hidden">
        {users?.map((u) => (
          <li key={u.id} className="p-4 flex items-center justify-between">
            <div>
              <p className="font-semibold text-sm">{u.name}</p>
              <p className="text-xs text-muted-foreground">{u.email}</p>
            </div>
            <span className="text-xs bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full font-medium">
              {u.role}
            </span>
          </li>
        ))}
      </ul>
    </div>
  );
}

5. Implementing Infinite Scrolling with useInfiniteQuery

For feeds, catalogs, and logs, loading paginated data continuously as the user scrolls provides a superior mobile experience compared to numeric page buttons.

Here is how to implement infinite scrolling with useInfiniteQuery and the browser's native IntersectionObserver:

// components/InfiniteUserFeed.tsx
'use client';

import React, { useEffect, useRef } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { userKeys } from '@/lib/queryKeys';

interface PaginatedUsersResponse {
  users: Array<{ id: string; name: string; email: string; role: string }>;
  nextPage: number | null;
  total: number;
}

async function fetchUserPage({ pageParam = 1 }): Promise<PaginatedUsersResponse> {
  const res = await fetch(`https://gen-endpoint.com/api/users?page=${pageParam}&limit=10`);
  if (!res.ok) throw new Error('Failed to fetch user page');
  return res.json();
}

export function InfiniteUserFeed() {
  const loadMoreRef = useRef<HTMLDivElement>(null);

  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
    error,
  } = useInfiniteQuery({
    queryKey: userKeys.infinite({}),
    queryFn: fetchUserPage,
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
  });

  // Attach IntersectionObserver to the sentinel element at the bottom
  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.5 }
    );

    if (loadMoreRef.current) {
      observer.observe(loadMoreRef.current);
    }

    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (status === 'pending') return <div className="p-8 text-center">Loading feed...</div>;
  if (status === 'error') return <div className="p-4 text-destructive">Error: {error.message}</div>;

  return (
    <div className="max-w-xl mx-auto p-4 space-y-4">
      <h3 className="text-lg font-bold">Infinite User Stream</h3>
      <div className="space-y-3">
        {data.pages.map((group, pageIndex) => (
          <React.Fragment key={pageIndex}>
            {group.users.map((user) => (
              <div key={user.id} className="p-4 border rounded-xl bg-card shadow-sm">
                <p className="font-semibold">{user.name}</p>
                <p className="text-xs text-muted-foreground">{user.email}</p>
              </div>
            ))}
          </React.Fragment>
        ))}
      </div>

      {/* Sentinel Element for Intersection Observer */}
      <div ref={loadMoreRef} className="py-6 text-center text-sm text-muted-foreground">
        {isFetchingNextPage
          ? 'Loading more members...'
          : hasNextPage
          ? 'Scroll down to load more'
          : 'You have reached the end of the directory.'}
      </div>
    </div>
  );
}

6. Prefetching on Hover for Zero-Latency Navigation

Users often pause for 200ms–400ms hovering their mouse over a link before clicking. You can exploit this pause to preload the data into TanStack Query's cache before the click occurs:

// components/UserCardWithPrefetch.tsx
'use client';

import React from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { userKeys } from '@/lib/queryKeys';
import Link from 'next/link';

export function UserCardWithPrefetch({ user }: { user: { id: string; name: string } }) {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    // Prefetch detail view before user clicks!
    queryClient.prefetchQuery({
      queryKey: userKeys.detail(user.id),
      queryFn: async () => {
        const res = await fetch(`https://gen-endpoint.com/api/users/${user.id}`);
        return res.json();
      },
      staleTime: 1000 * 60, // Consider prefetch fresh for 1 minute
    });
  };

  return (
    <Link
      href={`/users/${user.id}`}
      onMouseEnter={handleMouseEnter}
      className="p-4 border rounded-lg block hover:border-primary transition-colors"
    >
      <span className="font-medium">{user.name}</span>
    </Link>
  );
}

When the user clicks the link, the subsequent page's useQuery resolves instantly in 0ms from memory cache!


7. Optimistic Mutations with Automatic Rollback

When creating or modifying resources, do not make users stare at disabled loading spinners. Update the query cache immediately, and roll back if the server throws an error:

// hooks/useCreateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { userKeys } from '@/lib/queryKeys';

interface NewUserPayload {
  name: string;
  email: string;
  role: string;
}

export function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (newUser: NewUserPayload) => {
      const res = await fetch('https://gen-endpoint.com/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser),
      });
      if (!res.ok) throw new Error('Failed to create user on server');
      return res.json();
    },

    // 1. Optimistically update local cache
    onMutate: async (newUser) => {
      // Cancel outgoing refetches so they don't overwrite optimistic data
      await queryClient.cancelQueries({ queryKey: userKeys.lists() });

      // Snapshot previous cache state
      const previousUsers = queryClient.getQueryData(userKeys.lists()) || [];

      // Create synthetic optimistic user with temporary ID
      const optimisticUser = { id: 'temp-' + Date.now(), ...newUser };
      queryClient.setQueryData(userKeys.lists(), (old: any) => [...(old || []), optimisticUser]);

      return { previousUsers };
    },

    // 2. Rollback on failure
    onError: (err, newUser, context) => {
      if (context?.previousUsers) {
        queryClient.setQueryData(userKeys.lists(), context.previousUsers);
      }
      alert('Could not create user. Cache restored.');
    },

    // 3. Always refetch on settle to ensure server sync
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}

Summary & Practice

TanStack Query v5 provides the ultimate foundation for robust data-driven React applications. Pair it with Gen-Endpoint's free public REST APIs to prototype advanced data tables, search filters, and optimistic mutations without writing any backend code.

Free Developer Tooling

Ready to Test These Endpoints?

Access 23+ free mock REST APIs, test file uploads, and generate Next.js API handlers with zero signup.