Mastering TanStack Query (React Query v5) with Free Public REST Endpoints
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
useStateor 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.
- Read our dedicated React Mock API Guide.
- Explore ready-to-use Users API Documentation.
Ready to Test These Endpoints?
Access 23+ free mock REST APIs, test file uploads, and generate Next.js API handlers with zero signup.