Every React developer eventually writes the same buggy useEffect fetch block. I've done it myself, maybe a dozen times across different projects. The async cleanup, the stale closure, the missing loading state, it all adds up. Then TanStack Query exists and none of that is your problem anymore.
Quick Take: TanStack Query v5 (released 2024) ships first-class TypeScript 5.8 generics, automatic caching, and background refetching. It has over 42 million weekly npm downloads (npm stats, 2026). Define your types once, pass them to
useQuery, and you get fully type-safe server state in every component. The pattern in this guide works with React 19.1 and TypeScript 5.8.Key takeaways:
useEffect + fetchintroduces race conditions you don't see until production- TanStack Query v5 generics infer return types from
queryFnautomatically- Mutations follow the same generic pattern:
useMutation<Response, Error, Variables>()- Strong types make AI code completion (Copilot, Claude Code) dramatically more accurate
If you want a deeper reference on the library itself, options, devtools, plugins, and version history, our TanStack Query catalog entry covers the API surface; for the hooks side of the integration, React hooks best practices walks through the rules that useQuery is built on.
Why Is useEffect + fetch the Wrong Approach?
useEffect for data fetching is not a React anti-pattern because people are lazy. It's a problem because the browser doesn't know you sent a request, and React doesn't help you manage it. A 2023 React core team post on react.dev explicitly recommends a data-fetching library over raw useEffect for production apps. (React 19 Documentation, 2024)
Here's the core issue. Two rapid renders both fire a fetch. The second response arrives first. The first response lands later and overwrites a fresher result. You now display stale data. This is a race condition, and it's invisible in development where network calls resolve in milliseconds.
The other problems pile up fast:
- No caching, every mount re-fetches even if the data is seconds old
- No deduplication, two components requesting the same URL fire two separate HTTP calls
- No background refetch, data goes stale and there's no built-in way to refresh it
- Manual cleanup, forget the
isMountedflag and you get a state update on an unmounted component
I've shipped all four of these bugs. The fix isn't discipline, it's the right tool.
The Evolution of React Data Fetching
| Approach | Year | Caching | TypeScript | Race-safe | Background refresh |
|---|---|---|---|---|---|
fetch + useEffect | 2015 | No | Manual | No | No |
Axios + useEffect | 2016 | No | Manual | No | No |
| React Query v3 | 2021 | Yes | Partial | Yes | Yes |
| TanStack Query v5 | 2024 | Yes | First-class | Yes | Yes |
TanStack Query v5 didn't just add features, it rewrote the TypeScript generics from scratch. The v4 API had a few rough edges where the inferred type fell back to unknown. That's gone in v5.
The diagram shows the read path. Mutations follow a separate flow that we'll cover in Step 4.
Step 1: Define Your Data Types
Good types are the foundation. Everything downstream, your fetcher, your query, your component, inherits correctness from the interfaces you write here. In production I always define types in a dedicated types/ folder rather than colocating them with components. It makes them reusable across queries and mutations.
// types/user.ts
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string; // ISO 8601
}
export interface ApiError {
message: string;
code: number;
field?: string;
}
Want runtime safety on top of compile-time safety? Add a Zod schema.
// types/user.schema.ts
import { z } from 'zod';
export const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']),
createdAt: z.string(),
});
export type User = z.infer<typeof UserSchema>;
Zod adds about 12kb gzipped to your bundle. For apps hitting internal APIs you control, I skip it. For third-party APIs that occasionally send malformed responses, it's saved me hours of debugging in staging.
Step 2: The Type-Safe Fetcher Function
Your fetcher is just an async function that returns typed data. Keep it separate from the query config. This makes it testable and reusable across multiple queries.
// api/users.ts
import axios, { AxiosError } from 'axios';
import type { User } from '../types/user';
const BASE_URL = import.meta.env.VITE_API_URL ?? 'https://api.example.com';
export async function fetchUsers(): Promise<User[]> {
const { data } = await axios.get<User[]>(`${BASE_URL}/users`);
return data;
}
export async function fetchUserById(id: number): Promise<User> {
const { data } = await axios.get<User>(`${BASE_URL}/users/${id}`);
return data;
}
Why Axios here and not native fetch? Axios gives you automatic JSON parsing and response interceptors. My rule of thumb: if the project already has Axios in package.json, use it. If it doesn't, native fetch works fine for simple GETs, just remember to await res.json() and handle non-2xx status codes manually.
Step 3: useQuery with Full TypeScript
Here's the complete pattern. Pass your data type and error type as generics. TanStack Query v5 infers the return type of queryFn automatically, so you get full autocomplete on data without any casting. (TanStack Query v5 Docs, 2024)
// components/UserList.tsx
import { useQuery } from '@tanstack/react-query';
import type { AxiosError } from 'axios';
import { fetchUsers } from '../api/users';
import type { User, ApiError } from '../types/user';
export function UserList() {
const {
data: users,
isLoading,
isError,
error,
} = useQuery<User[], AxiosError<ApiError>>({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 1000 * 60 * 5, // 5 minutes
});
if (isLoading) return <p>Loading users...</p>;
if (isError) return <p>Error: {error.response?.data.message ?? error.message}</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name}, {user.role}
</li>
))}
</ul>
);
}
Notice staleTime. Setting it to 5 minutes means TanStack Query won't refetch the user list on every component mount, it returns the cached version instead. This is the default behaviour that useEffect can never give you for free.
What about dynamic query keys? Use an array with the variable inside it. TanStack Query watches the key and refetches when it changes, no useEffect dependency array to forget.
const { data: user } = useQuery<User, AxiosError<ApiError>>({
queryKey: ['users', userId],
queryFn: () => fetchUserById(userId),
enabled: Boolean(userId), // don't fetch if userId is undefined
});
The enabled flag is underused. It solves the common pattern of "wait for one query to finish before starting another" without any callback chaining. Pass enabled: Boolean(data?.userId) and the dependent query simply won't run until the first one resolves.
For more on query-key conventions, hierarchical invalidation, and the cache-time vs stale-time mental model, see the TanStack Query reference, it covers the patterns this guide doesn't repeat.
Step 4: Mutations with Optimistic Updates
useMutation follows the same generic pattern. Three type parameters: the response type, the error type, and the variables type you pass to mutate().
// components/CreateUserForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { AxiosError } from 'axios';
import type { User, ApiError } from '../types/user';
interface CreateUserVars {
name: string;
email: string;
role: User['role'];
}
async function createUser(vars: CreateUserVars): Promise<User> {
const { data } = await axios.post<User>('/users', vars);
return data;
}
export function CreateUserForm() {
const queryClient = useQueryClient();
const { mutate, isPending, isError, error } = useMutation<
User,
AxiosError<ApiError>,
CreateUserVars
>({
mutationFn: createUser,
onSuccess: (newUser) => {
// Optimistic: add new user to the cache immediately
queryClient.setQueryData<User[]>(['users'], (prev = []) => [
...prev,
newUser,
]);
},
onError: () => {
// Rollback: invalidate and let a fresh fetch restore correct state
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
return (
<button
disabled={isPending}
onClick={() => mutate({ name: 'Jane Doe', email: '[email protected]', role: 'editor' })}
>
{isPending ? 'Creating...' : 'Create user'}
</button>
);
}
The onSuccess + onError pair is the optimistic update pattern. Update the cache immediately on success, invalidate on error. Users see instant feedback. If the server rejects the request, the list snaps back to truth. Clean and honest.
How Good TypeScript Types Improve AI Code Suggestions
I've run the same prompt through GitHub Copilot on a React codebase with strict TypeScript interfaces versus a codebase that used any everywhere. The difference in suggestion quality was immediate, with proper types, Copilot completed useQuery calls with correct generic parameters and accurate property names on the first attempt. With any, it guessed wrong on the response shape half the time.
This matters more than ever in 2026. Tools like Copilot and Claude Code read your type definitions when generating completions. A well-typed User interface tells the model exactly which fields exist, what types they are, and what operations make sense. An any type tells it nothing, you get generic boilerplate at best, subtly broken code at worst.
The TanStack Query generics amplify this effect. When your useQuery is typed as useQuery<User[], AxiosError>, your IDE and your AI assistant both know that data is User[] | undefined. They won't suggest data.id on a list. They'll suggest data?.map(...) instead.
The investment pays off fast. Write the types once. Every query, every mutation, every AI suggestion downstream benefits.
React Data Fetching with TypeScript and TanStack Query is one of those combinations where the pieces genuinely reinforce each other. TanStack Query solves the state management problems. TypeScript 5.8 generics catch the shape mismatches. Together they make the kind of data layer that's easy to extend and hard to break accidentally.
The next step is setting up a QueryClient with global defaultOptions for retry logic and error boundaries, but that's a separate guide. For now, wire up your first useQuery, add the types, and notice how much cleaner the component tree becomes.
Want more on this? Subscribe to the Coding Dunia newsletter, one practical React or TypeScript tip, weekly, no filler.
For TypeScript patterns that keep your data layer clean and maintainable, including discriminated unions for loading states and mapped types for keeping API schemas in sync, the TypeScript clean code guide covers 15 of them with before-and-after examples.
Related Tutorials
- Claude Code for React
- React 19 guide
- React Compiler: Practical Guide to Auto-Memoization
- React Hook Form vs Formik
- Dashboard Design Patterns for Web Apps - the design layer above TanStack Query: card hierarchy, data density, three-state loading patterns and table UX that pair directly with the data model you build here