TypeScript
TanStack Query is written in TypeScript and provides excellent type safety out of the box.Type Inference
React Query automatically infers types from your query functions:import { useQuery } from '@tanstack/react-query'
interface Post {
id: number
title: string
body: string
}
function Posts() {
// data is automatically typed as Post[] | undefined
const { data } = useQuery({
queryKey: ['posts'],
queryFn: async (): Promise<Post[]> => {
const response = await fetch('/api/posts')
return response.json()
},
})
return (
<ul>
{data?.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
The return type of
queryFn determines the type of data. Add explicit Promise return types to your async functions for better type inference.Generic Type Parameters
Explicitly specify types when automatic inference isn’t sufficient:import { useQuery } from '@tanstack/react-query'
import type { UseQueryResult } from '@tanstack/react-query'
interface Post {
id: number
title: string
}
interface ApiError {
message: string
code: number
}
// Specify all generic types
const query: UseQueryResult<Post[], ApiError> = useQuery<
Post[], // TQueryFnData
ApiError, // TError
Post[], // TData
string[] // TQueryKey
>({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts')
if (!response.ok) {
throw { message: 'Failed to fetch', code: response.status }
}
return response.json()
},
})
Type Parameters
| Parameter | Description | Default |
|---|---|---|
TQueryFnData | Type returned by queryFn | unknown |
TError | Type of error thrown | DefaultError |
TData | Type of data property | TQueryFnData |
TQueryKey | Type of queryKey | QueryKey |
Transforming Data
Use theselect option to transform query data with full type safety:
interface Post {
id: number
title: string
body: string
userId: number
}
interface PostSummary {
id: number
title: string
}
function PostTitles() {
const { data } = useQuery<Post[], Error, PostSummary[]>({
queryKey: ['posts'],
queryFn: fetchPosts,
select: (posts) => posts.map((post) => ({
id: post.id,
title: post.title,
})),
})
// data is typed as PostSummary[] | undefined
return <div>{data?.map(post => post.title).join(', ')}</div>
}
The
select function is memoized, so transformations only run when the underlying data changes.Query Options Helper
UsequeryOptions for reusable, type-safe query definitions:
import { queryOptions, useQuery } from '@tanstack/react-query'
interface Post {
id: number
title: string
}
// Define reusable query options
const postQueryOptions = (postId: number) =>
queryOptions({
queryKey: ['post', postId],
queryFn: async (): Promise<Post> => {
const response = await fetch(`/api/posts/${postId}`)
return response.json()
},
staleTime: 5000,
})
// Use in components with full type inference
function PostDetails({ postId }: { postId: number }) {
const { data } = useQuery(postQueryOptions(postId))
return <h1>{data?.title}</h1>
}
// Also works with queryClient methods
import { useQueryClient } from '@tanstack/react-query'
function prefetchPost(postId: number) {
const queryClient = useQueryClient()
queryClient.prefetchQuery(postQueryOptions(postId))
}
Mutation Types
Type mutations for safe data updates:import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { UseMutationResult } from '@tanstack/react-query'
interface Todo {
id: number
title: string
completed: boolean
}
interface CreateTodoInput {
title: string
}
function CreateTodo() {
const queryClient = useQueryClient()
const mutation: UseMutationResult<
Todo, // TData - returned data type
Error, // TError - error type
CreateTodoInput, // TVariables - mutate function parameters
unknown // TContext - context type for optimistic updates
> = useMutation({
mutationFn: async (input: CreateTodoInput): Promise<Todo> => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return response.json()
},
onSuccess: (data) => {
// data is typed as Todo
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old ? [...old, data] : [data]
)
},
})
return (
<button onClick={() => mutation.mutate({ title: 'New Todo' })}>
Create
</button>
)
}
Mutation Options Helper
Create reusable mutation configurations:import { mutationOptions, useMutation } from '@tanstack/react-query'
interface UpdateTodoInput {
id: number
title?: string
completed?: boolean
}
const updateTodoMutation = mutationOptions({
mutationFn: async (input: UpdateTodoInput) => {
const response = await fetch(`/api/todos/${input.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return response.json()
},
})
function TodoItem({ todo }: { todo: Todo }) {
const mutation = useMutation(updateTodoMutation)
return <button onClick={() => mutation.mutate({ id: todo.id, completed: true })}>Complete</button>
}
Infinite Queries
Type infinite scroll queries:import { useInfiniteQuery } from '@tanstack/react-query'
import type { InfiniteData } from '@tanstack/react-query'
interface Post {
id: number
title: string
}
interface PostsPage {
posts: Post[]
nextCursor: number | null
}
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery<PostsPage, Error>({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }): Promise<PostsPage> => {
const response = await fetch(`/api/posts?cursor=${pageParam}`)
return response.json()
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
// data is typed as InfiniteData<PostsPage> | undefined
return (
<div>
{data?.pages.map((page) =>
page.posts.map((post) => <div key={post.id}>{post.title}</div>)
)}
{hasNextPage && <button onClick={() => fetchNextPage()}>Load More</button>}
</div>
)
}
Type-Safe Query Keys
Define strongly-typed query keys:// Define query key factory
const todoKeys = {
all: ['todos'] as const,
lists: () => [...todoKeys.all, 'list'] as const,
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
}
// Use in queries
function TodoList({ filters }: { filters: string }) {
const { data } = useQuery({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
})
return <div>...</div>
}
// Invalidate with type safety
function TodoActions() {
const queryClient = useQueryClient()
const invalidateAll = () => {
queryClient.invalidateQueries({ queryKey: todoKeys.all })
}
const invalidateList = () => {
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })
}
return <div>...</div>
}
Using
as const ensures query keys are readonly tuples, providing better type inference and preventing accidental mutations.Custom Hooks
Create type-safe custom hooks:import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'
interface Post {
id: number
title: string
}
// Custom query hook
function usePost(
postId: number,
options?: Omit<UseQueryOptions<Post, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: ['post', postId],
queryFn: async (): Promise<Post> => {
const response = await fetch(`/api/posts/${postId}`)
return response.json()
},
...options,
})
}
// Custom mutation hook
function useCreatePost(
options?: UseMutationOptions<Post, Error, { title: string }>
) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (input: { title: string }): Promise<Post> => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
...options,
})
}
// Usage
function PostComponent({ postId }: { postId: number }) {
const { data: post } = usePost(postId, { staleTime: 5000 })
const createPost = useCreatePost({
onSuccess: (data) => console.log('Created:', data.title),
})
return <div>{post?.title}</div>
}
Error Types
Define custom error types:class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public code: string
) {
super(message)
this.name = 'ApiError'
}
}
function usePosts() {
return useQuery<Post[], ApiError>({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts')
if (!response.ok) {
throw new ApiError(
'Failed to fetch posts',
response.status,
'FETCH_ERROR'
)
}
return response.json()
},
})
}
function PostsList() {
const { data, error } = usePosts()
if (error) {
// error is typed as ApiError
return (
<div>
Error {error.statusCode}: {error.message} ({error.code})
</div>
)
}
return <div>...</div>
}
Suspense Queries
Use suspense mode with full type safety:import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
interface User {
id: number
name: string
}
function UserProfile({ userId }: { userId: number }) {
// data is always defined (never undefined) in suspense mode
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: async (): Promise<User> => {
const response = await fetch(`/api/users/${userId}`)
return response.json()
},
})
// No need to check if data exists
return <h1>{data.name}</h1>
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userId={1} />
</Suspense>
)
}
useSuspenseQuery returns data that is never undefined, so you don’t need to handle the loading state manually.Advanced Types
Narrowing Types
function usePost(postId: number) {
return useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
})
}
function PostComponent({ postId }: { postId: number }) {
const { data, isSuccess } = usePost(postId)
if (isSuccess) {
// TypeScript knows data is defined here
return <h1>{data.title}</h1>
}
return null
}
Discriminated Unions
type Result<T, E = Error> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: E }
| { status: 'success'; data: T }
function QueryResult({ result }: { result: Result<Post[]> }) {
switch (result.status) {
case 'idle':
return <div>Idle</div>
case 'loading':
return <div>Loading...</div>
case 'error':
return <div>Error: {result.error.message}</div>
case 'success':
return <ul>{result.data.map(post => <li key={post.id}>{post.title}</li>)}</ul>
}
}
Type Utilities
React Query exports helpful type utilities:import type {
QueryKey,
QueryFunction,
UseQueryResult,
UseMutationResult,
InfiniteData,
QueryClient,
} from '@tanstack/react-query'
// Extract data type from query result
type PostData = UseQueryResult<Post[]>['data']
// Extract error type
type PostError = UseQueryResult<Post[], CustomError>['error']
// InfiniteData type for infinite queries
type InfinitePostsData = InfiniteData<PostsPage>
Always use
import type for type-only imports to ensure they’re removed during compilation and don’t affect bundle size.Next Steps
DevTools
Debug type issues with React Query DevTools
Server-Side Rendering
Type-safe SSR with Next.js