How to Use TanStack with TypeScript
Most frameworks give you type-safe components. TanStack gives you type-safe data. Query a backend endpoint with useQuery, and TypeScript knows exactly what shape the response has. No manual type assertions, no casting to any, just automatic inference from your fetch function's return type. Switch from React to Vue? The same type safety comes with you. The patterns you learn work the same way across frameworks because TanStack libraries are built framework-agnostic from the start.
TanStack is a suite of tools (Query, Router, Table, and more) that handle data fetching, routing, and UI state with TypeScript-first design. The type inference is what makes it work so well. Define your API call once with a proper return type, and that type flows through queries, mutations, cached data, and every component that uses it. This guide shows you how to build type-safe applications using TanStack Query for data fetching, TanStack Router for navigation, and TanStack Table for data grids. These patterns work whether you're using React, Vue, Solid, or Svelte.
TanStack Query: Type-Safe Data Fetching
TanStack Query (formerly React Query) handles asynchronous state management and server-state synchronization with strong TypeScript support. The library works across frameworks, so these patterns apply whether you're using React, Vue, Solid, or Svelte.
Setting Up TanStack Query with TypeScript
First, install TanStack Query for your framework:
# React
npm install @tanstack/react-query
# Vue
npm install @tanstack/vue-query
# Solid
npm install @tanstack/solid-query
# Svelte
npm install @tanstack/svelte-query
Create a query client and provider (example using React):
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
Type Inference from Query Functions
TanStack Query's biggest TypeScript feature is automatic type inference. When your queryFn has a well-defined return type, TanStack Query infers the data type without manual annotations:
interface UserProfile {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
// TypeScript infers the return type from this function
const fetchUserProfile = async (userId: number): Promise<UserProfile> => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.statusText}`);
}
try {
return await response.json();
} catch (e) {
throw new Error('Invalid JSON response from server');
}
};
function UserProfileCard({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUserProfile(userId),
});
// TypeScript knows data is UserProfile | undefined
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
<span>Role: {data.role}</span>
</div>
);
}
The key is defining explicit return types on your data fetching functions. Without them, TypeScript falls back to any, and you lose all type safety.
Using queryOptions for Reusable Queries
The queryOptions helper preserves type inference when you extract query configuration into reusable functions. This is useful for sharing queries across components:
import { queryOptions, useQuery } from '@tanstack/react-query';
interface TeamMember {
id: string;
name: string;
department: string;
active: boolean;
}
const fetchTeamMembers = async (): Promise<TeamMember[]> => {
const response = await fetch('/api/team');
if (!response.ok) {
throw new Error(`Failed to fetch team members: ${response.statusText}`);
}
return response.json();
};
// Extract query options into a factory function
function teamMembersQuery() {
return queryOptions({
queryKey: ['team', 'members'],
queryFn: fetchTeamMembers,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Use in components
function TeamList() {
const { data } = useQuery(teamMembersQuery());
// TypeScript knows data is TeamMember[] | undefined
return (
<ul>
{data?.map(member => (
<li key={member.id}>{member.name} - {member.department}</li>
))}
</ul>
);
}
// Use with query client for prefetching or cache access
function TeamDashboard() {
const queryClient = useQueryClient();
// Prefetch team members
useEffect(() => {
queryClient.prefetchQuery(teamMembersQuery());
}, [queryClient]);
// Access cached data with full type safety
const cachedTeam = queryClient.getQueryData(teamMembersQuery().queryKey);
// TypeScript knows cachedTeam is TeamMember[] | undefined
return <TeamList />;
}
Typing Mutations with mutationOptions
Mutations work similarly to queries, with type inference from your mutation function:
import { useMutation, mutationOptions } from '@tanstack/react-query';
interface CreateUserInput {
name: string;
email: string;
}
interface User {
id: number;
name: string;
email: string;
createdAt: string;
}
const createUser = async (input: CreateUserInput): Promise<User> => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(`Failed to create user: ${response.statusText}`);
}
return response.json();
};
// Extract mutation options for reusability
function createUserMutation() {
return mutationOptions({
mutationKey: ['createUser'],
mutationFn: createUser,
});
}
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
...createUserMutation(),
onSuccess: (newUser) => {
// TypeScript knows newUser is User
queryClient.invalidateQueries({ queryKey: ['users'] });
console.log(`Created user: ${newUser.name}`);
},
});
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.error && <p>Error: {mutation.error.message}</p>}
</form>
);
}
Advanced TanStack Query Patterns
Custom Error Types
By default, TanStack Query expects errors to be Error objects. You can customize this globally or per-query:
import '@tanstack/react-query';
// Register a global error type
declare module '@tanstack/react-query' {
interface Register {
defaultError: {
message: string;
code: number;
details?: Record<string, unknown>;
};
}
}
// Helper to convert fetch errors to your custom error type
async function handleApiError(response: Response): Promise<never> {
const errorData = await response.json().catch(() => ({}));
throw {
message: errorData.message || response.statusText,
code: response.status,
details: errorData.details,
};
}
// Use in data fetching functions
const fetchData = async (): Promise<DataType> => {
const response = await fetch('/api/data');
if (!response.ok) {
await handleApiError(response);
}
return response.json();
};
// Now all queries use this error type
const { error } = useQuery({
queryKey: ['data'],
queryFn: fetchData,
});
if (error) {
console.log(error.code); // TypeScript knows this exists
console.log(error.details); // Fully typed
}
For individual queries, specify the error type explicitly:
interface ApiError {
statusCode: number;
message: string;
}
const { error } = useQuery<UserProfile, ApiError>({
queryKey: ['user', userId],
queryFn: fetchUserProfile,
});
// TypeScript knows error is ApiError | null
Discriminated Unions for Query States
TanStack Query returns a discriminated union based on the status field. Use status flags to narrow types:
function ProductList() {
const query = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
// Use status flags for type narrowing
if (query.isLoading) {
return <div>Loading products...</div>;
}
if (query.isError) {
// TypeScript knows error exists here
return <div>Error: {query.error.message}</div>;
}
if (query.isSuccess) {
// TypeScript knows data exists here and is not undefined
return (
<ul>
{query.data.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
return null;
}
Global Type Registration
Register global types for query keys, mutation keys, and metadata to enforce consistency across your application:
import '@tanstack/react-query';
type QueryKey = ['users'] | ['users', number] | ['products'] | ['products', string];
declare module '@tanstack/react-query' {
interface Register {
queryKey: QueryKey;
mutationKey: QueryKey;
}
}
// Now TypeScript enforces these patterns
const { data } = useQuery({
queryKey: ['users', 123], // Valid
queryFn: fetchUser,
});
const invalid = useQuery({
queryKey: ['invalid'], // TypeScript error: not in QueryKey union
queryFn: fetchSomething,
});
Query Factories Pattern
Centralize your queries using factory functions for maintainability and type safety:
// queries/userQueries.ts
export const userQueries = {
all: () => queryOptions({
queryKey: ['users'],
queryFn: fetchAllUsers,
}),
detail: (userId: number) => queryOptions({
queryKey: ['users', userId],
queryFn: () => fetchUserById(userId),
}),
posts: (userId: number) => queryOptions({
queryKey: ['users', userId, 'posts'],
queryFn: () => fetchUserPosts(userId),
}),
};
// In components
function UserDetails({ userId }: { userId: number }) {
const userQuery = useQuery(userQueries.detail(userId));
const postsQuery = useQuery(userQueries.posts(userId));
// Both queries have full type inference
}
// Invalidate related queries easily
queryClient.invalidateQueries({ queryKey: userQueries.all().queryKey });
This pattern, inspired by tRPC, centralizes endpoints, generates stable query keys, and keeps type safety consistent across your application.
TanStack Router: Type-Safe Navigation
TanStack Router brings full type safety to routing using TypeScript's module declaration merging. While it's currently React-focused, the patterns show TanStack's TypeScript-first approach.
Registering Your Router Types
TanStack Router uses declaration merging to provide type inference across your entire application:
import { createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
const router = createRouter({ routeTree });
// Register router types globally
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
export default router;
This one-time registration gives you type-safe navigation, params, and search params throughout your app without importing types everywhere.
Type-Safe Route Parameters
Define routes with typed parameters using createFileRoute:
// routes/blog/post.$postId.tsx
import { createFileRoute } from '@tanstack/react-router';
interface BlogPost {
id: string;
title: string;
content: string;
author: string;
}
export const Route = createFileRoute('/blog/post/$postId')({
loader: async ({ params }) => {
// TypeScript knows params.postId exists and is a string
const response = await fetch(`/api/posts/${params.postId}`);
return response.json() as Promise<BlogPost>;
},
});
function BlogPostComponent() {
const { postId } = Route.useParams();
const post = Route.useLoaderData();
// Full type inference for params and loader data
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author}</p>
<div>{post.content}</div>
</article>
);
}
Type-Safe Search Parameters
Validate search params with schemas for robust type safety:
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const productSearchSchema = z.object({
page: z.number().catch(1),
filter: z.string().catch(''),
sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
});
type ProductSearch = z.infer<typeof productSearchSchema>;
export const Route = createFileRoute('/shop/products')({
validateSearch: productSearchSchema,
});
function ProductList() {
const { page, filter, sort } = Route.useSearch();
// TypeScript knows all these properties exist with correct types
return (
<div>
<p>Page {page} - Sorted by {sort}</p>
{filter && <p>Filter: {filter}</p>}
</div>
);
}
Type-Safe Navigation
Links and navigation automatically get type checking:
import { Link, useNavigate } from '@tanstack/react-router';
function Navigation() {
const navigate = useNavigate();
return (
<nav>
{/* TypeScript validates route params */}
<Link
to="/blog/post/$postId"
params={{ postId: '123' }}
>
Blog Post
</Link>
{/* TypeScript validates search params */}
<Link
to="/shop/products"
search={{ page: 2, sort: 'newest' }}
>
Products
</Link>
<button onClick={() => {
// Programmatic navigation with type safety
navigate({
to: '/shop/products',
search: { page: 1, filter: 'electronics', sort: 'price' },
});
}}>
Go to Products
</button>
</nav>
);
}
TanStack Table: Typed Data Grids
TanStack Table is a headless UI library for building tables and data grids with full TypeScript support across React, Vue, Solid, and Svelte.
Defining Table Types with createColumnHelper
Use createColumnHelper with your data type for type-safe column definitions:
import { createColumnHelper, useReactTable, getCoreRowModel } from '@tanstack/react-table';
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
const columnHelper = createColumnHelper<Product>();
const columns = [
columnHelper.accessor('name', {
header: 'Product Name',
cell: info => info.getValue(),
}),
columnHelper.accessor('price', {
header: 'Price',
cell: info => `$${info.getValue().toFixed(2)}`,
}),
columnHelper.accessor('category', {
header: 'Category',
}),
columnHelper.accessor('inStock', {
header: 'Status',
cell: info => (
<span className={info.getValue() ? 'text-green' : 'text-red'}>
{info.getValue() ? 'In Stock' : 'Out of Stock'}
</span>
),
}),
];
Creating Type-Safe Table Instances
Use the generic type parameter when creating table instances:
import { flexRender } from '@tanstack/react-table';
function ProductTable({ data }: { data: Product[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Generic Table Components
Create reusable table components using generics:
import { ColumnDef, useReactTable, getCoreRowModel } from '@tanstack/react-table';
interface TableProps<T> {
data: T[];
columns: ColumnDef<T>[];
onRowClick?: (row: T) => void;
}
function DataTable<T>({ data, columns, onRowClick }: TableProps<T>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table>
{/* Table rendering logic */}
<tbody>
{table.getRowModel().rows.map(row => (
<tr
key={row.id}
onClick={() => onRowClick?.(row.original)}
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// Use with different data types
function Dashboard() {
return (
<>
<DataTable data={products} columns={productColumns} />
<DataTable data={users} columns={userColumns} />
</>
);
}
Using TanStack Across Different Frameworks
TanStack libraries are framework-agnostic, with adapters for React, Vue, Solid, and Svelte. The TypeScript patterns remain consistent across frameworks:
React
import { useQuery } from '@tanstack/react-query';
function UserProfile() {
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
return <div>{data?.name}</div>;
}
Vue
<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query';
interface User {
id: number;
name: string;
}
const fetchUser = async (): Promise<User> => {
const response = await fetch('/api/user');
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
};
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
});
</script>
<template>
<div>{{ data?.name }}</div>
</template>
Solid
import { createQuery } from '@tanstack/solid-query';
function UserProfile() {
const query = createQuery(() => ({
queryKey: ['user'],
queryFn: fetchUser,
}));
return <div>{query.data?.name}</div>;
}
The type inference and patterns work identically across all frameworks, so you can apply these techniques regardless of your framework choice.
Easy Mistakes to Avoid
Using any Instead of Proper Types
Don't skip typing your data fetching functions:
// Bad: No type inference
const fetchData = async () => {
const res = await fetch('/api/data');
return res.json(); // Returns any
};
// Good: Explicit return type
const fetchData = async (): Promise<DataType> => {
const res = await fetch('/api/data');
return res.json();
};
Breaking Type Inference with Spreading
Spreading query options can break type inference:
// Type inference breaks
const baseOptions = {
queryKey: ['users'],
queryFn: fetchUsers,
};
const { data } = useQuery({ ...baseOptions }); // data is unknown
// Use queryOptions instead
const userQueryOptions = () => queryOptions({
queryKey: ['users'],
queryFn: fetchUsers,
});
const { data } = useQuery(userQueryOptions()); // data is properly typed
Forgetting skipToken for Conditional Queries
When query parameters might be undefined, use skipToken:
import { useQuery, skipToken } from '@tanstack/react-query';
function UserProfile({ userId }: { userId?: number }) {
const { data, isLoading, isError } = useQuery({
queryKey: ['user', userId],
// Skip the query if userId is undefined
queryFn: userId ? () => fetchUser(userId) : skipToken,
});
// When using skipToken, the query won't run until userId is defined
if (!userId) {
return <div>Please select a user</div>;
}
if (isLoading) return <div>Loading...</div>;
if (isError) return <div>Error loading user</div>;
return (
<div>
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
Overriding Error Types Too Broadly
Specifying custom error types disables type inference for other generics:
// This breaks inference for data type
const { data } = useQuery<unknown, CustomError>({
queryKey: ['data'],
queryFn: fetchData, // data type is unknown
});
// Better: Register global error type or use separate queries
Not Registering Router Types
Forgetting to register your router instance breaks type inference:
// Don't forget this declaration
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
// Without it, you lose type safety in navigation
Production Patterns with TanStack TypeScript
Centralized API Client
Create a typed API client that works with TanStack Query:
interface User {
id: number;
name: string;
email: string;
createdAt: string;
}
interface CreateUserInput {
name: string;
email: string;
}
interface ApiError {
message: string;
code: number;
}
class ApiClient {
private baseUrl = '/api';
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error: ApiError = await response.json();
throw error;
}
return response.json();
}
async getUsers(): Promise<User[]> {
return this.request<User[]>('/users');
}
async getUser(id: number): Promise<User> {
return this.request<User>(`/users/${id}`);
}
async createUser(data: CreateUserInput): Promise<User> {
return this.request<User>('/users', {
method: 'POST',
body: JSON.stringify(data),
});
}
}
export const api = new ApiClient();
// Use with TanStack Query
const userQuery = queryOptions({
queryKey: ['users'],
queryFn: () => api.getUsers(),
});
Integration with Convex
Convex provides a type-safe backend that works seamlessly with TanStack Query. Convex automatically generates TypeScript types for your backend functions:
import { useQuery } from '@tanstack/react-query';
import { api } from '../convex/_generated/api';
import { useConvexAuth, ConvexHttpClient } from 'convex/react';
const convexClient = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
function MessagesView() {
const { data: messages } = useQuery({
queryKey: ['messages'],
queryFn: () => convexClient.query(api.messages.list),
// Convex provides end-to-end type safety
});
return (
<ul>
{messages?.map(message => (
<li key={message._id}>{message.text}</li>
))}
</ul>
);
}
Convex provides its own React hooks (useQuery, useMutation) with built-in real-time subscriptions. You can use TanStack Query with Convex when you need advanced features like optimistic updates, complex caching strategies, or want to integrate Convex data with other API sources in a unified caching layer. For most Convex applications, the native Convex hooks are simpler and give you automatic real-time updates.
Building Type-Safe Applications with TanStack
TanStack's libraries give you strong TypeScript support across the entire stack. Here are the key patterns to remember:
- Define explicit return types on all data fetching functions for automatic type inference
- Use
queryOptionsandmutationOptionsto preserve types when extracting query configuration - Register global types for errors, query keys, and metadata for consistency
- Use discriminated unions with status flags for type-safe state handling
- Use query factories to centralize endpoint definitions and keep type safety consistent
- Register your router instance with module declaration merging for full type inference
- Create generic table components with
createColumnHelperfor reusable data grids - Apply the same patterns across React, Vue, Solid, and Svelte
TanStack's framework-agnostic approach means you can learn these patterns once and apply them everywhere. The TypeScript-first design catches errors at compile time and gives you good IDE support through autocomplete and inline documentation.
Need a type-safe backend that works well with TanStack? Get started with Convex to see how it gives you end-to-end type safety from your database to your UI components.