โ† Back to DevBytes

TanStack Query TypeScript: Strongly Typed Applications

TanStack Query TypeScript: Strongly Typed Applications

TanStack Query (formerly React Query) has become the go-to data-fetching library for modern React applications. When paired with TypeScript, it transforms from a simple caching layer into a powerful, type-safe data management system that catches bugs at compile time rather than runtime. This tutorial walks through everything you need to build strongly typed applications with TanStack Query.

What Is TanStack Query?

TanStack Query is an asynchronous state management library that handles fetching, caching, synchronizing, and updating server state in web applications. Unlike global state libraries such as Redux or Zustand, TanStack Query focuses specifically on server state โ€” the data that lives on your backend and needs to be fetched, refreshed, and kept in sync.

The library provides hooks like useQuery and useMutation that automatically manage loading states, error handling, caching, background refetching, and request deduplication. When combined with TypeScript, every piece of data flowing through your queries becomes statically typed, eliminating an entire class of runtime errors.

Why Type Safety Matters

Without strong typing, data fetching in JavaScript applications is a common source of bugs. A backend might change a field name, return null where you expected a string, or nest data differently than anticipated. These issues often surface only in production when real users hit the affected code paths.

Setting Up the Project

Start by installing TanStack Query and its dependencies. You will also need TypeScript configured in your project.

npm install @tanstack/react-query
npm install -D typescript @types/react @types/node

Create a tsconfig.json with strict mode enabled to get the most out of type safety:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["DOM", "DOM.Iterable", "ES2020"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "jsx": "react-jsx",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

Wrap your application with the QueryClientProvider:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { App } from './App';

const queryClient = new QueryClient();

export function Root() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  );
}

Defining Type-Safe API Functions

The foundation of a strongly typed TanStack Query setup begins with your API layer. Define the shapes of your data using TypeScript interfaces or types, then write fetch functions that return those types.

// types.ts
export interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  createdAt: string;
}

export interface Post {
  id: number;
  title: string;
  body: string;
  authorId: number;
  publishedAt: string | null;
  tags: string[];
}

export interface PaginatedResponse<T> {
  data: T[];
  total: number;
  page: number;
  pageSize: number;
}

Now create typed fetch functions. Using a generic helper keeps things DRY and consistent:

// api.ts
import { User, Post, PaginatedResponse } from './types';

async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Request failed: ${response.status} ${response.statusText}`);
  }
  return response.json() as Promise<T>;
}

export const api = {
  getUser: (id: number) => fetchJson<User>(`/api/users/${id}`),
  getUsers: (page: number) => fetchJson<PaginatedResponse<User>>(`/api/users?page=${page}`),
  getPost: (id: number) => fetchJson<Post>(`/api/posts/${id}`),
  getPosts: () => fetchJson<Post[]>(`/api/posts`),
};

Notice how the generic fetchJson<T> function ensures every API call returns a properly typed promise. This is the cornerstone of type safety โ€” if you accidentally pass the wrong type, TypeScript will flag it immediately.

Using useQuery with TypeScript

The useQuery hook accepts a query key, a query function, and optional configuration. TanStack Query infers the data type from the return type of your query function automatically.

import { useQuery } from '@tanstack/react-query';
import { api } from './api';

export function UserProfile({ userId }: { userId: number }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => api.getUser(userId),
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  // data is inferred as User | undefined
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
      <p>Role: {data.role}</p>
    </div>
  );
}

Because api.getUser returns Promise<User>, TanStack Query infers data as User | undefined. The undefined union accounts for the initial state before data has loaded. After your loading and error checks, TypeScript narrows the type so you can safely access data.name and other fields.

Working with Query Keys

Query keys identify and cache your queries. In TypeScript applications, it is worth creating a structured approach to query keys so they remain consistent and refactor-safe. A common pattern is to use a factory object:

// queryKeys.ts
export const queryKeys = {
  users: {
    all: ['users'] as const,
    lists: () => [...queryKeys.users.all, 'list'] as const,
    list: (page: number) => [...queryKeys.users.lists(), { page }] as const,
    details: () => [...queryKeys.users.all, 'detail'] as const,
    detail: (id: number) => [...queryKeys.users.details(), id] as const,
  },
  posts: {
    all: ['posts'] as const,
    details: () => [...queryKeys.posts.all, 'detail'] as const,
    detail: (id: number) => [...queryKeys.posts.details(), id] as const,
  },
};

Using as const ensures the query key arrays are treated as readonly tuples, which prevents accidental mutations and gives you precise type inference. Now you can use these keys throughout your application:

const { data } = useQuery({
  queryKey: queryKeys.users.detail(userId),
  queryFn: () => api.getUser(userId),
});

Type-Safe Mutations

Mutations handle data modifications โ€” creating, updating, and deleting resources. The useMutation hook is fully typed, with separate type parameters for the variables you pass in, the response you get back, and the error type.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from './api';
import { queryKeys } from './queryKeys';
import { User } from './types';

interface UpdateUserInput {
  id: number;
  name?: string;
  email?: string;
}

async function updateUser(input: UpdateUserInput): Promise<User> {
  const response = await fetch(`/api/users/${input.id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(input),
  });
  if (!response.ok) throw new Error('Failed to update user');
  return response.json() as Promise<User>;
}

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

  return useMutation({
    mutationFn: updateUser,
    onSuccess: (updatedUser) => {
      // Invalidate the specific user detail query
      queryClient.invalidateQueries({
        queryKey: queryKeys.users.detail(updatedUser.id),
      });
      // Also invalidate user lists since the data changed
      queryClient.invalidateQueries({
        queryKey: queryKeys.users.lists(),
      });
    },
  });
}

When you use this mutation hook in a component, the mutate function expects an UpdateUserInput argument. Passing the wrong shape produces a compile-time error:

function EditUserForm({ user }: { user: User }) {
  const updateUser = useUpdateUser();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    updateUser.mutate({
      id: user.id,
      name: 'New Name',
      email: 'new@example.com',
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button type="submit" disabled={updateUser.isPending}>
        {updateUser.isPending ? 'Saving...' : 'Save'}
      </button>
      {updateUser.isError && (
        <p>Error: {updateUser.error.message}</p>
      )}
    </form>
  );
}

Optimistic Updates with Type Safety

Optimistic updates improve perceived performance by updating the UI immediately before the server confirms the change. TanStack Query supports this through the onMutate callback, and TypeScript helps you keep the cache data correctly typed throughout the process.

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Post } from './types';
import { queryKeys } from './queryKeys';

interface EditPostInput {
  id: number;
  title: string;
  body: string;
}

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

  return useMutation({
    mutationFn: async (input: EditPostInput): Promise<Post> => {
      const res = await fetch(`/api/posts/${input.id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(input),
      });
      if (!res.ok) throw new Error('Failed to edit post');
      return res.json() as Promise<Post>;
    },
    onMutate: async (input) => {
      // Cancel outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({
        queryKey: queryKeys.posts.detail(input.id),
      });

      // Snapshot the previous value for rollback
      const previousPost = queryClient.getQueryData<Post>(
        queryKeys.posts.detail(input.id)
      );

      // Optimistically update the cache
      queryClient.setQueryData<Post>(queryKeys.posts.detail(input.id), {
        ...previousPost,
        ...input,
        id: input.id,
      });

      // Return context with the snapshot for rollback
      return { previousPost };
    },
    onError: (_error, input, context) => {
      // Roll back to the snapshot on error
      if (context?.previousPost) {
        queryClient.setQueryData(
          queryKeys.posts.detail(input.id),
          context.previousPost
        );
      }
    },
    onSettled: (data, _error, input) => {
      // Always refetch after error or success to ensure sync
      queryClient.invalidateQueries({
        queryKey: queryKeys.posts.detail(input.id),
      });
    },
  });
}

The getQueryData<Post> and setQueryData<Post> calls explicitly specify the cache type. This ensures that when you spread previousPost into the new object, TypeScript knows exactly what fields are available and flags any typos.

Creating a Typed Query Client Setup

For larger applications, centralizing your query configuration keeps behavior consistent. You can define default options that apply to every query and mutation:

import { QueryClient } from '@tanstack/react-query';

export function createQueryClient(): QueryClient {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
        gcTime: 5 * 60_000,
        retry: 3,
        refetchOnWindowFocus: true,
      },
      mutations: {
        retry: 1,
      },
    },
  });
}

Using a factory function instead of constructing the client inline makes it easy to create fresh instances in tests without polluting your test suite with shared cache state.

Typing Error Responses

By default, TanStack Query types errors as Error. However, real-world APIs often return structured error objects with status codes and field-level validation messages. You can customize the error type to match your backend's error format:

interface ApiError {
  status: number;
  message: string;
  fieldErrors?: Record<string, string>;
}

async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    const errorBody = await response.json().catch(() => ({
      status: response.status,
      message: response.statusText,
    }));
    throw errorBody as ApiError;
  }
  return response.json() as Promise<T>;
}

// Use the error type in your query
const { data, error } = useQuery<User, ApiError>({
  queryKey: queryKeys.users.detail(1),
  queryFn: () => api.getUser(1),
});

if (error) {
  console.log(error.status);
  console.log(error.message);
  if (error.fieldErrors) {
    Object.entries(error.fieldErrors).forEach(([field, msg]) => {
      console.log(`${field}: ${msg}`);
    });
  }
}

Explicitly passing useQuery<User, ApiError> overrides the default error type, giving you typed access to error.status, error.message, and error.fieldErrors throughout your component.

Using Selectors for Data Transformation

The select option lets you transform or filter cached data before it reaches your component. This is useful when multiple components need different views of the same underlying data. The return type of select becomes the type of data in your component.

function UserEmailList() {
  const { data } = useQuery({
    queryKey: queryKeys.users.lists(),
    queryFn: () => api.getUsers(1),
    select: (response) => response.data.map((user) => ({
      id: user.id,
      email: user.email,
    })),
  });

  // data is inferred as { id: number; email: string }[] | undefined
  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.email}</li>
      ))}
    </ul>
  );
}

Because the select function returns a new shape, TypeScript automatically infers the transformed type. This means data in the component is no longer PaginatedResponse<User> but rather the array of simplified objects, giving you precise type safety at every layer.

Dependent Queries

Sometimes a query depends on the result of another. TanStack Query handles this with the enabled option, and TypeScript ensures you handle the case where the prerequisite data is not yet available.

function UserPosts({ userId }: { userId: number }) {
  const { data: user } = useQuery({
    queryKey: queryKeys.users.detail(userId),
    queryFn: () => api.getUser(userId),
  });

  const { data: posts } = useQuery({
    queryKey: ['posts', 'byUser', userId],
    queryFn: () => api.getPosts().then((all) => all.filter((p) => p.authorId === userId)),
    enabled: user !== undefined,
  });

  return (
    <div>
      {user && <h1>Posts by {user.name}</h1>}
      {posts?.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}
    </div>
  );
}

Infinite Queries with Types

The useInfiniteQuery hook handles paginated data that loads incrementally. TypeScript infers both the page data type and the structure of the accumulated pages array.

import { useInfiniteQuery } from '@tanstack/react-query';

interface PostsPage {
  data: Post[];
  nextPage: number | null;
}

async function fetchPostsPage({ pageParam = 1 }): Promise<PostsPage> {
  const res = await fetch(`/api/posts?page=${pageParam}`);
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json() as Promise<PostsPage>;
}

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: fetchPostsPage,
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  // data.pages is inferred as PostsPage[]
  const allPosts = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <div>
      {allPosts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}
      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? 'Loading...' : 'Load More'}
      </button>
    </div>
  );
}

Best Practices

Integrating Zod for Runtime Validation

Compile-time types alone cannot protect you from a backend that returns unexpected data. Combining TanStack Query with Zod gives you both runtime validation and static types derived from a single schema definition.

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string(),
});

type User = z.infer<typeof UserSchema>;

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('Failed to fetch user');
  const json = await res.json();
  return UserSchema.parse(json); // throws if data doesn't match
}

// useQuery infers User from the return type of fetchUser
const { data } = useQuery({
  queryKey: queryKeys.users.detail(1),
  queryFn: () => fetchUser(1),
});

With this approach, if the backend returns a malformed response, Zod throws a descriptive error before the data reaches your component. The z.infer utility generates the TypeScript type from the schema, so you never have to maintain separate type definitions and validation logic.

Conclusion

TanStack Query and TypeScript are a natural pairing for building robust, maintainable data-driven applications. By typing your API functions, structuring query keys with factories, customizing error types, and optionally adding runtime validation with Zod, you create a system where data flows through your application with full type safety at every stage. The result is fewer runtime bugs, faster refactoring, and a significantly better developer experience. Start by typing your API layer thoroughly โ€” everything else flows from there โ€” and gradually adopt patterns like query key factories and custom mutation hooks as your application grows. The investment in type safety pays dividends every time you refactor, onboard a new developer, or ship a feature with confidence.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles