TanStack Query + TypeScript: Typing Queries, Mutations, and Infinite Lists
tanstack-queryreacttypescriptdata-fetchingtyping

TanStack Query + TypeScript: Typing Queries, Mutations, and Infinite Lists

TTypeScript Page Editorial
2026-06-12
10 min read

A practical guide to typing TanStack Query with TypeScript for queries, mutations, cache updates, and infinite lists.

TanStack Query works especially well with TypeScript, but the experience depends on one habit: let types flow from a small, trusted data layer instead of forcing generics into every hook call. This guide shows how to type useQuery, useMutation, dependent queries, and infinite lists in a way that stays readable as your app grows. The goal is not just to silence errors, but to make query keys, API responses, optimistic updates, and pagination feel predictable in day-to-day React work.

Overview

If you are searching for a practical TanStack Query + TypeScript guide, the most important idea is simple: type your fetchers well, then let TanStack Query infer as much as possible.

Many examples start with long generic signatures directly inside useQuery or useMutation. That can work, but it often becomes brittle. The better long-term pattern is to define:

  • a clear response type for your API layer
  • a query key factory with stable shapes
  • small hook wrappers that expose a clean interface to components

This article focuses on those patterns.

The examples assume a React application using TanStack Query and TypeScript in strict mode. If your app still has weak compiler settings, it is worth tightening them over time. A stricter setup makes data fetching code much easier to trust. If you need a refresher on compiler settings, see TypeScript Compiler Options Cheat Sheet: What Each Setting Actually Does.

Before writing any hooks, decide where types should come from. In most codebases, you want this order of truth:

  1. runtime-validated or documented API contracts
  2. typed request functions
  3. query and mutation hooks
  4. UI components

That direction matters. Components should consume typed data, not define what the data is supposed to look like after the fact. If your current code works the other way around, review How to Type API Responses in TypeScript Without Lying to the Compiler.

Core framework

This section gives you a repeatable mental model for typing TanStack Query in React projects.

1. Start with domain types, not hook generics

Suppose your app fetches a list of users and a single user by id.

type User = {
  id: string;
  email: string;
  displayName: string;
  role: 'admin' | 'member';
};

type UsersResponse = {
  items: User[];
  total: number;
};

These types should describe real server responses as closely as possible. Avoid "UI-corrected" versions unless you are transforming the data deliberately.

2. Type the fetcher return value

Your fetch function is where inference starts paying off.

async function getUsers(): Promise<UsersResponse> {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Error('Failed to load users');
  return res.json();
}

async function getUser(userId: string): Promise<User> {
  const res = await fetch(`/api/users/${userId}`);
  if (!res.ok) throw new Error('Failed to load user');
  return res.json();
}

Once these functions return precise promises, useQuery usually infers the data type automatically.

3. Use query key factories

Query keys are easier to manage when they are centralized and typed by convention.

export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: { search?: string }) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (userId: string) => [...userKeys.details(), userId] as const,
};

The as const here is important. It preserves literal tuple types so TanStack Query can distinguish keys more precisely. If you regularly compare type aliases, interfaces, and literal object patterns, Interface vs Type in TypeScript: Current Best Practices and TypeScript Enums vs Union Types vs as const Objects are useful complements.

4. Let useQuery infer data whenever possible

function useUsers() {
  return useQuery({
    queryKey: userKeys.lists(),
    queryFn: getUsers,
  });
}

function useUser(userId: string) {
  return useQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => getUser(userId),
    enabled: Boolean(userId),
  });
}

In these examples, TypeScript can infer:

  • data as UsersResponse | undefined for useUsers
  • data as User | undefined for useUser

You do not need to write useQuery<User> unless inference breaks or you are intentionally shaping the output with select.

5. Understand the four generic slots before using them

TanStack Query supports explicit generics, but they are best used sparingly. The common mental model is:

useQuery<TQueryFnData, TError, TData, TQueryKey>(...)

In practice:

  • TQueryFnData: raw data returned by the query function
  • TError: error type, often left broad unless you have a consistent error model
  • TData: transformed data after select
  • TQueryKey: the query key type

If you provide one generic manually, you may need to provide more than you expected. That is one reason over-annotating hook calls often makes code harder to maintain.

6. Use select for derived data, and type the result deliberately

When you transform query data, the final data type changes.

function useUserNames() {
  return useQuery<UsersResponse, Error, string[]>({
    queryKey: userKeys.lists(),
    queryFn: getUsers,
    select: (response) => response.items.map((user) => user.displayName),
  });
}

Here, the query function still returns UsersResponse, but the hook exposes string[]. This is one of the clearest cases where explicit generics are worth it.

7. Prefer custom hooks over repeating query options in components

Instead of spreading query details across your UI, wrap them in hooks with stable return types.

export function useUserDetail(userId: string) {
  return useQuery({
    queryKey: userKeys.detail(userId),
    queryFn: () => getUser(userId),
    enabled: !!userId,
    staleTime: 30_000,
  });
}

This keeps your component simple and makes future changes easier. If you work in a larger app or monorepo, centralizing patterns like this helps avoid type drift. For that setup, see TypeScript Monorepo Setup Guide: pnpm, Project References, and Shared Types.

Practical examples

These examples cover the patterns most teams use in production React code: standard queries, mutations, dependent queries, and infinite scrolling.

Typed query with params

type UserFilters = {
  search?: string;
};

async function getFilteredUsers(filters: UserFilters): Promise<UsersResponse> {
  const params = new URLSearchParams();
  if (filters.search) params.set('search', filters.search);

  const res = await fetch(`/api/users?${params.toString()}`);
  if (!res.ok) throw new Error('Failed to load filtered users');
  return res.json();
}

function useFilteredUsers(filters: UserFilters) {
  return useQuery({
    queryKey: userKeys.list(filters),
    queryFn: () => getFilteredUsers(filters),
  });
}

The main rule is that all values affecting the result should be represented in the query key. If the filters change but the key does not, caching becomes misleading.

Dependent query with enabled

type Team = {
  id: string;
  name: string;
};

async function getTeam(teamId: string): Promise<Team> {
  const res = await fetch(`/api/teams/${teamId}`);
  if (!res.ok) throw new Error('Failed to load team');
  return res.json();
}

function useTeam(teamId: string | undefined) {
  return useQuery({
    queryKey: ['team', teamId] as const,
    queryFn: () => getTeam(teamId as string),
    enabled: !!teamId,
  });
}

This pattern is common, but note the cast in the query function. It works, but it is not ideal. A cleaner version narrows before calling the hook or builds a wrapper that accepts only a confirmed string. Type narrowing usually produces more honest code than asserting away undefined.

Typing useMutation

Mutations usually need three types:

  • the returned data
  • the error
  • the variables passed into the mutation function
type CreateUserInput = {
  email: string;
  displayName: string;
};

async function createUser(input: CreateUserInput): Promise<User> {
  const res = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });

  if (!res.ok) throw new Error('Failed to create user');
  return res.json();
}

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

  return useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: userKeys.all });
    },
  });
}

Again, the mutation function carries most of the type information. In many cases, that is enough for TypeScript to infer mutate variables and mutation results correctly.

Typing optimistic updates carefully

Optimistic updates are where weak types can create subtle bugs. Keep cache shapes consistent with query results.

function useCreateUserOptimistic() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: createUser,
    onMutate: async (newUserInput) => {
      await queryClient.cancelQueries({ queryKey: userKeys.lists() });

      const previous = queryClient.getQueryData<UsersResponse>(userKeys.lists());

      queryClient.setQueryData<UsersResponse>(userKeys.lists(), (old) => {
        if (!old) return { items: [], total: 0 };

        const optimisticUser: User = {
          id: 'temp-id',
          email: newUserInput.email,
          displayName: newUserInput.displayName,
          role: 'member',
        };

        return {
          ...old,
          items: [optimisticUser, ...old.items],
          total: old.total + 1,
        };
      });

      return { previous };
    },
    onError: (_error, _variables, context) => {
      if (context?.previous) {
        queryClient.setQueryData(userKeys.lists(), context.previous);
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: userKeys.all });
    },
  });
}

The key detail is that getQueryData and setQueryData are explicitly typed with the real cache shape. Without that, optimistic logic can quietly drift from the actual query contract.

Typing infinite queries

Infinite lists need two things typed clearly: the page response and the page parameter.

type UsersPage = {
  items: User[];
  nextCursor?: string;
};

async function getUsersPage(cursor?: string): Promise<UsersPage> {
  const params = new URLSearchParams();
  if (cursor) params.set('cursor', cursor);

  const res = await fetch(`/api/users/infinite?${params.toString()}`);
  if (!res.ok) throw new Error('Failed to load users page');
  return res.json();
}

function useInfiniteUsers() {
  return useInfiniteQuery({
    queryKey: ['users', 'infinite'] as const,
    initialPageParam: undefined as string | undefined,
    queryFn: ({ pageParam }) => getUsersPage(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
}

From here, data?.pages is typed as an array of UsersPage. A common next step is flattening the result:

const allUsers = data?.pages.flatMap((page) => page.items) ?? [];

This stays easy to read because the page type was defined cleanly up front.

A reusable options pattern

As projects grow, some teams extract query options into shared builders. This can work well when you want consistent keys and easy prefetching.

function usersQueryOptions() {
  return {
    queryKey: userKeys.lists(),
    queryFn: getUsers,
  };
}

function useUsersList() {
  return useQuery(usersQueryOptions());
}

This pattern becomes especially useful in framework code where server-side prefetching, route loaders, or test helpers need the same query definition.

If you are using Next.js, pair this style with a route-aware TypeScript setup from Next.js + TypeScript Guide: App Router Patterns That Stay Type-Safe.

Common mistakes

Most TypeScript friction with TanStack Query comes from a few repeatable mistakes. Fixing these usually improves both correctness and readability.

1. Overwriting inference too early

A common anti-pattern looks like this:

useQuery<User[], Error>({ ... })

If the fetcher actually returns { items: User[]; total: number }, you have just lied to the compiler. The code may still compile, but now your cache and UI types disagree. Let the fetcher define the source shape first.

2. Using unstable or incomplete query keys

If filters, ids, pagination inputs, or locale affect the result, they belong in the key. Query keys are part of the type story because they represent cache identity. A wrong key often leads to bugs that types cannot rescue later.

3. Asserting away undefined in dependent queries

Casts like teamId as string are tempting. They are sometimes acceptable in narrow situations, but they should feel like a code smell. Prefer control flow that proves the value exists before use.

4. Confusing transformed data with raw data

If you use select, remember that data is no longer the raw server response. This is one of the main cases where explicit generics or a dedicated custom hook improve clarity.

5. Treating errors as fully typed when they are not

In many apps, thrown errors come from different sources: fetch, custom wrappers, runtime validation, and business logic. Unless you have a consistent error model, be careful about assuming a single rich error shape everywhere.

6. Skipping runtime validation for untrusted responses

TypeScript checks your code, not your network. If your API is external, loosely controlled, or still evolving, add runtime validation at the boundary. For that comparison, see Zod vs io-ts vs Valibot vs Yup for TypeScript Runtime Validation.

7. Letting tooling issues masquerade as typing issues

Sometimes the problem is not TanStack Query at all. Path alias misconfiguration, module resolution issues, or mixed ESM and CommonJS imports can produce confusing errors around hooks and types. If imports look correct but the compiler disagrees, check How to Fix TypeScript Module Resolution Errors and TypeScript Path Aliases Guide for Vite, Next.js, Node, Jest, and ts-node.

8. Repeating query logic across components

If every component invents its own query key, fetcher wrapper, and data transformation, your types will diverge over time. Consolidate patterns into hooks or query option builders. That gives you one place to update when backend contracts change.

When to revisit

This topic is worth revisiting whenever your data-fetching architecture changes, because small shifts in TanStack Query patterns can ripple through your TypeScript design.

Review your current approach when any of these happen:

  • you upgrade TanStack Query and its recommended hook patterns change
  • you move from ad hoc fetch calls to a shared API client
  • you add runtime validation for server responses
  • you introduce server rendering, route loaders, or prefetching
  • you refactor query keys to support filtering, pagination, or multitenancy
  • you notice frequent use of as assertions around query code

A practical maintenance checklist looks like this:

  1. Audit your fetchers. Make sure each one returns an honest promise type.
  2. Centralize query keys with as const tuples.
  3. Wrap repeated query logic in custom hooks or options builders.
  4. Check every select callback and confirm the exposed data type is intentional.
  5. Type cache reads and writes explicitly in optimistic updates.
  6. Validate external or unstable API responses at runtime.
  7. Keep your lint and TypeScript setup strict enough to catch drift early.

If your codebase is large, combine this with a clean linting baseline from ESLint + TypeScript Flat Config Guide.

The durable rule is straightforward: put the most precise types at the edge of your system, then let TanStack Query carry them through the app. When you do that, useQuery, useMutation, and useInfiniteQuery become easier to read, easier to refactor, and far less likely to drift away from reality.

For most teams, the next useful step is not adding more generics. It is simplifying the data layer so inference can do its job.

Related Topics

#tanstack-query#react#typescript#data-fetching#typing
T

TypeScript Page Editorial

Senior SEO Editor

Senior editor and content strategist. Writing about technology, design, and the future of digital media. Follow along for deep dives into the industry's moving parts.

2026-06-12T03:52:09.809Z