Skip to content

React Data Fetching with TypeScript and TanStack Query

Learn type-safe data fetching in React using TanStack Query v5. Compare fetch, Axios, and useQuery with production-ready TypeScript examples.

· · 10 min read
Architecture diagram: React component calls useQuery, cache resolves fresh hits instantly while network fetch with Zod

Quick Take

TanStack Query v5 gives you caching, background refetching, and rock-solid TypeScript generics out of the box. Here's how to wire it up from scratch.

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 + fetch introduces race conditions you don't see until production
  • TanStack Query v5 generics infer return types from queryFn automatically
  • 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 isMounted flag 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

ApproachYearCachingTypeScriptRace-safeBackground refresh
fetch + useEffect2015NoManualNoNo
Axios + useEffect2016NoManualNoNo
React Query v32021YesPartialYesYes
TanStack Query v52024YesFirst-classYesYes

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.

Component useQuery(key, fn) Cache check staleTime? Network fetch fetcher() -> JSON Cached data data, isStale UI render typed payload stale/miss fresh hit writes cache

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.

Frequently Asked Questions

What is the difference between useQuery and useEffect for data fetching?
useQuery from TanStack Query handles caching, background refetching, loading/error states, and stale-while-revalidate out of the box. useEffect with fetch requires you to build all of that manually, and it's easy to introduce race conditions or memory leaks. For anything beyond a one-off prototype, useQuery is the right choice.
Does TanStack Query v5 work with TypeScript?
Yes. TanStack Query v5 ships first-class TypeScript generics. You pass your data type and error type as generic parameters: useQuery<User[], AxiosError>(). The queryFn return type is inferred automatically, so TypeScript catches type mismatches at compile time.
When should I use Axios instead of the native fetch API?
Axios is worth adding when you need automatic JSON parsing, request/response interceptors for auth tokens, consistent timeout handling, or upload progress events. The native fetch API in 2026 is capable but still requires manual JSON parsing and lacks interceptors. For simple GET requests, fetch is fine. For anything with auth, retries, or file uploads, Axios or a thin wrapper around fetch saves time.
How do I handle TypeScript types with TanStack Query mutations?
Use useMutation with two generic parameters: useMutation<ResponseType, ErrorType, VariablesType>(). The mutationFn should be typed to accept your variables and return a Promise of the response type. TypeScript then enforces the correct shape at every call site.