Skip to main content
react

What TanStack Gets Right About Developer Experience

TanStack's framework-agnostic, TypeScript-first approach produces APIs that feel right. Here's the specific design decisions behind that developer experience.


5 min read

TanStack does something most library ecosystems fail at: their APIs feel consistent across packages. TanStack Query, Router, Table, and Form share the same philosophy — framework-agnostic core, typed adapters per framework, headless architecture, devtools as separate packages. The result is that learning one TanStack library makes the next one easier to pick up. That is not an accident. It is a deliberate architecture decision that most ecosystems do not make.


Framework-agnostic by design

The TanStack core packages export pure logic with no framework dependencies. @tanstack/query-core contains the QueryClient, cache management, and observer logic. @tanstack/react-query is an adapter — it wires the core to React's useSyncExternalStore. The same separation exists for Vue, Solid, Svelte, and Angular.

This matters beyond "use it in any framework." It means the core logic is testable without React, the types do not carry framework types as noise, and upgrading a framework adapter does not touch the query logic. When React Query v5 dropped to a single unified hook signature, the adapter changed but the core did not.

TypeScript types that flow without annotation

TanStack Query's inference is the best demonstration of what "TypeScript-first" actually means. Define a query function, and the types propagate through without a single manual annotation:

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

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`)
  if (!res.ok) throw new Error('Failed to fetch user')
  return res.json() as Promise<{ id: string; name: string; email: string }>
}

function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })

  // data is typed as { id: string; name: string; email: string } | undefined
  // error is typed as Error | null
  // No <UserType> generic needed anywhere in this component
}

The queryKey is typed as a readonly array, so queryKey: ['user', userId] produces readonly ['user', string]. This flows into cache invalidation — when you call queryClient.invalidateQueries({ queryKey: ['user'] }), the type system can verify the key shape. In practice you still use strings, but the inference chain means you rarely write type annotations for query state.

Cache invalidation that makes sense

The query key hierarchy is the best design decision TanStack Query made. Keys are arrays, and invalidation works by prefix match:

// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: ['user'] })

// Invalidate only this specific user
queryClient.invalidateQueries({ queryKey: ['user', userId] })

// Invalidate user list and this user in one call
queryClient.invalidateQueries({ queryKey: ['user'] })

This is a cache design that scales. Most caching solutions force you to track cache keys manually or use string patterns with regex. TanStack's approach is structural — the key shape encodes the relationship between queries, and invalidation respects that structure.

TanStack Table's column definition API

TanStack Table is headless in the most complete sense — it does not render anything. It produces a table instance with rows, cells, headers, and all the state management for sorting, filtering, pagination, and selection. You bring DOM and styles.

The column definition API is where the TypeScript investment pays off:

import { createColumnHelper } from '@tanstack/react-table'

type User = { id: string; name: string; email: string; role: 'admin' | 'user' }

const columnHelper = createColumnHelper<User>()

const columns = [
  columnHelper.accessor('name', {
    header: 'Name',
    cell: info => info.getValue(),
    sortingFn: 'alphanumeric',
  }),
  columnHelper.accessor('role', {
    header: 'Role',
    cell: info => <Badge>{info.getValue()}</Badge>,
    filterFn: 'equals',
  }),
  columnHelper.display({
    id: 'actions',
    cell: info => <RowActions row={info.row} />,
  }),
]

columnHelper.accessor('name', ...) narrows the key to keyof User — you cannot type 'nme' without a type error. info.getValue() inside the accessor for role returns 'admin' | 'user', not string. The type flows from the data definition through the column definition into the cell renderer. This is what the library actually ships — it is not a simplified example.

Devtools as a separate package

@tanstack/react-query-devtools is not bundled with the main package. You import it explicitly and render it in development only. This is the correct default — devtools are large, they add bundle weight, and they are never needed in production.

import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

// Only in dev — tree-shake in production
<QueryClientProvider client={queryClient}>
  {children}
  {process.env.NODE_ENV === 'development' && <ReactQueryDevtools />}
</QueryClientProvider>

Contrast this with libraries that ship devtools-adjacent code in the main bundle and gate it with a flag. Separate packages are the right model because they can be version-pinned independently, updated without touching production, and dropped with zero runtime cost.

The coherence across TanStack's ecosystem — shared patterns, shared types, shared devtools philosophy — is what actually separates good library design from a collection of packages by the same author.