TanStack - Ekosystem Bibliotek dla Profesjonalnych Aplikacji
Czym jest TanStack?
TanStack to kolekcja profesjonalnych, framework-agnostic bibliotek stworzonych przez Tannera Linsley'a. Każda biblioteka rozwiązuje konkretny problem w sposób type-safe i wydajny:
- TanStack Query - Zarządzanie server state (dawniej React Query)
- TanStack Table - Headless logika tabel
- TanStack Router - Type-safe routing
- TanStack Form - Zarządzanie formularzami
- TanStack Virtual - Wirtualizacja długich list
Wszystkie biblioteki działają z React, Vue, Solid, Svelte i vanilla JS.
TanStack Query (React Query v5)
Dlaczego Query?
Tradycyjne podejście do fetching danych w React:
// ❌ Klasyczne podejście - dużo boilerplate
function Posts() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(setPosts)
.catch(setError)
.finally(() => setLoading(false))
}, [])
// A co z: refetching, cache, stale data, deduplication, pagination...?
}Z TanStack Query:
// ✅ Eleganckie, z pełnym cache i stanem
function Posts() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
})
// Automatycznie: cache, refetch, deduplication, background updates!
}Instalacja
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools # opcjonalneKonfiguracja
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
// Dane są "świeże" przez 60 sekund
staleTime: 60 * 1000,
// Cache trzymany przez 5 minut
gcTime: 5 * 60 * 1000,
// Retry 3 razy przy błędzie
retry: 3,
// Refetch przy focus okna
refetchOnWindowFocus: true,
},
},
}))
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}useQuery - Pobieranie danych
import { useQuery } from '@tanstack/react-query'
// Podstawowe użycie
function PostsList() {
const {
data,
isLoading,
isError,
error,
isFetching,
isStale,
refetch,
} = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts')
if (!response.ok) throw new Error('Network error')
return response.json()
},
})
if (isLoading) return <Skeleton />
if (isError) return <Error message={error.message} />
return (
<div>
{/* isFetching = background refetch */}
{isFetching && <RefreshIndicator />}
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={() => refetch()}>Odśwież</button>
</div>
)
}Query Keys - klucz do cache
// Proste klucze
useQuery({ queryKey: ['posts'], ... })
useQuery({ queryKey: ['users'], ... })
// Parametryzowane klucze
useQuery({ queryKey: ['post', postId], ... })
useQuery({ queryKey: ['posts', { status: 'published' }], ... })
useQuery({ queryKey: ['user', userId, 'posts'], ... })
// Klucze są serialized - kolejność ma znaczenie!
['posts', { status: 'published', page: 1 }]
// !== ['posts', { page: 1, status: 'published' }]
// Ale z opcją: queryKeyHashFn można to zmienićuseQuery z parametrami
function PostDetail({ postId }: { postId: string }) {
const { data: post } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
// Nie wykonuj query jeśli brak ID
enabled: !!postId,
})
return <article>{post?.title}</article>
}
// Z filtrami
function FilteredPosts({ status, page }: { status: string; page: number }) {
const { data } = useQuery({
queryKey: ['posts', { status, page }],
queryFn: () => fetchPosts({ status, page }),
// Zachowaj poprzednie dane podczas ładowania nowych
placeholderData: keepPreviousData,
})
return <PostList posts={data?.posts} />
}useMutation - Modyfikacja danych
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePost() {
const queryClient = useQueryClient()
const createPost = useMutation({
mutationFn: async (newPost: { title: string; content: string }) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
})
if (!response.ok) throw new Error('Failed to create post')
return response.json()
},
// Optimistic update
onMutate: async (newPost) => {
// Anuluj aktywne query
await queryClient.cancelQueries({ queryKey: ['posts'] })
// Snapshot poprzednich danych
const previousPosts = queryClient.getQueryData(['posts'])
// Optimistycznie dodaj nowy post
queryClient.setQueryData(['posts'], (old: Post[]) => [
{ id: 'temp', ...newPost },
...old,
])
return { previousPosts }
},
// Przy błędzie - rollback
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts'], context?.previousPosts)
},
// Po sukcesie lub błędzie - invalidate
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
return (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
})
}}>
<input name="title" required />
<textarea name="content" required />
<button disabled={createPost.isPending}>
{createPost.isPending ? 'Tworzenie...' : 'Utwórz post'}
</button>
{createPost.isError && <p className="text-red-500">{createPost.error.message}</p>}
</form>
)
}Infinite Queries (Pagination)
import { useInfiniteQuery } from '@tanstack/react-query'
function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/posts?cursor=${pageParam}`)
return response.json()
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Ładowanie...'
: hasNextPage
? 'Załaduj więcej'
: 'Koniec listy'}
</button>
</div>
)
}Prefetching i Hydration (Next.js)
// app/posts/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { PostsList } from './posts-list'
export default async function PostsPage() {
const queryClient = new QueryClient()
// Prefetch na serwerze
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () => fetch('https://api.example.com/posts').then(r => r.json()),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList />
</HydrationBoundary>
)
}
// posts-list.tsx (Client Component)
'use client'
import { useQuery } from '@tanstack/react-query'
export function PostsList() {
// Te dane są już w cache z serwera - instant render!
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
})
return <ul>{data?.map(post => <li key={post.id}>{post.title}</li>)}</ul>
}TanStack Table
Dlaczego TanStack Table?
TanStack Table to headless biblioteka - dostarcza logikę, Ty kontrolujesz rendering. Obsługuje:
- Sortowanie (multi-column)
- Filtrowanie (global i per-column)
- Paginację
- Row selection
- Column resizing/reordering
- Grupowanie
- Agregacje
Instalacja
npm install @tanstack/react-tablePodstawowa tabela
import {
useReactTable,
getCoreRowModel,
flexRender,
createColumnHelper,
} from '@tanstack/react-table'
interface User {
id: string
name: string
email: string
status: 'active' | 'inactive'
createdAt: Date
}
const columnHelper = createColumnHelper<User>()
const columns = [
columnHelper.accessor('name', {
header: 'Nazwa',
cell: info => info.getValue(),
}),
columnHelper.accessor('email', {
header: 'Email',
cell: info => (
<a href={`mailto:${info.getValue()}`} className="text-blue-500">
{info.getValue()}
</a>
),
}),
columnHelper.accessor('status', {
header: 'Status',
cell: info => (
<span className={`px-2 py-1 rounded text-sm ${
info.getValue() === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{info.getValue()}
</span>
),
}),
columnHelper.accessor('createdAt', {
header: 'Data utworzenia',
cell: info => info.getValue().toLocaleDateString('pl-PL'),
}),
]
function UsersTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}Sortowanie
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
SortingState,
} from '@tanstack/react-table'
function SortableTable({ data }: { data: User[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="cursor-pointer select-none"
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</th>
))}
</tr>
))}
</thead>
{/* ... */}
</table>
)
}Filtrowanie
import {
useReactTable,
getFilteredRowModel,
ColumnFiltersState,
} from '@tanstack/react-table'
function FilterableTable({ data }: { data: User[] }) {
const [globalFilter, setGlobalFilter] = useState('')
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const table = useReactTable({
data,
columns,
state: { globalFilter, columnFilters },
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
return (
<div>
{/* Global search */}
<input
type="text"
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Szukaj..."
className="mb-4 px-4 py-2 border rounded"
/>
{/* Column filter */}
<select
value={(table.getColumn('status')?.getFilterValue() as string) ?? ''}
onChange={e => table.getColumn('status')?.setFilterValue(e.target.value)}
>
<option value="">Wszystkie statusy</option>
<option value="active">Aktywni</option>
<option value="inactive">Nieaktywni</option>
</select>
<table>{/* ... */}</table>
</div>
)
}Paginacja
import { getPaginationRowModel } from '@tanstack/react-table'
function PaginatedTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 10,
},
},
})
return (
<div>
<table>{/* ... */}</table>
<div className="flex items-center gap-2 mt-4">
<button
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
{'<<'}
</button>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<span>
Strona {table.getState().pagination.pageIndex + 1} z{' '}
{table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<button
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
{'>>'}
</button>
<select
value={table.getState().pagination.pageSize}
onChange={e => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map(pageSize => (
<option key={pageSize} value={pageSize}>
Pokaż {pageSize}
</option>
))}
</select>
</div>
</div>
)
}Row Selection
import { RowSelectionState } from '@tanstack/react-table'
function SelectableTable({ data }: { data: User[] }) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const columns = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
// ... inne kolumny
]
const table = useReactTable({
data,
columns,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
return (
<div>
<p>{selectedRows.length} zaznaczonych</p>
{selectedRows.length > 0 && (
<button onClick={() => deleteSelected(selectedRows)}>
Usuń zaznaczone
</button>
)}
<table>{/* ... */}</table>
</div>
)
}TanStack Router
Dlaczego TanStack Router?
- 100% type-safe - pełna inferencja typów dla params, search, loader data
- File-based routing - opcjonalne, jak w Next.js
- Nested layouts - z typowanymi outlet'ami
- Search params - walidacja z Zod
- Loaders - data fetching z integracją TanStack Query
Instalacja
npm install @tanstack/react-routerDefinicja Routes
// routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<div>
<Header />
<main>
<Outlet />
</main>
<Footer />
</div>
),
})
// routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
return <h1>Strona główna</h1>
}
// routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
// Loader z pełnymi typami
loader: async ({ params }) => {
// params.postId jest type-safe!
return fetchPost(params.postId)
},
component: PostPage,
})
function PostPage() {
// useLoaderData zwraca typ z loader!
const post = Route.useLoaderData()
return <article>{post.title}</article>
}Search Params z walidacją
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().default(1),
filter: z.enum(['all', 'active', 'inactive']).default('all'),
search: z.string().optional(),
})
export const Route = createFileRoute('/users')({
validateSearch: searchSchema,
component: UsersPage,
})
function UsersPage() {
// search jest w pełni typowane!
const { page, filter, search } = Route.useSearch()
const navigate = Route.useNavigate()
return (
<div>
<input
value={search ?? ''}
onChange={e => navigate({
search: prev => ({ ...prev, search: e.target.value })
})}
/>
<select
value={filter}
onChange={e => navigate({
search: prev => ({ ...prev, filter: e.target.value })
})}
>
<option value="all">Wszyscy</option>
<option value="active">Aktywni</option>
<option value="inactive">Nieaktywni</option>
</select>
<Pagination
page={page}
onPageChange={p => navigate({
search: prev => ({ ...prev, page: p })
})}
/>
</div>
)
}Type-safe Navigation
import { Link, useNavigate } from '@tanstack/react-router'
function Navigation() {
const navigate = useNavigate()
return (
<nav>
{/* Link z autocomplete dla to, params, search */}
<Link to="/">Home</Link>
<Link to="/posts/$postId" params={{ postId: '123' }}>
Post 123
</Link>
<Link
to="/users"
search={{ page: 1, filter: 'active' }}
>
Aktywni użytkownicy
</Link>
{/* Programowa nawigacja */}
<button onClick={() => navigate({ to: '/dashboard' })}>
Dashboard
</button>
</nav>
)
}TanStack Form
Dlaczego TanStack Form?
- Headless - pełna kontrola nad UI
- Type-safe - pełna inferencja dla form values i errors
- Async validation - z debounce
- Field arrays - dynamiczne pola
- Adaptery walidacji - Zod, Yup, Valibot
Instalacja
npm install @tanstack/react-form @tanstack/zod-form-adapter zodPodstawowy formularz
import { useForm } from '@tanstack/react-form'
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(2, 'Minimum 2 znaki'),
email: z.string().email('Nieprawidłowy email'),
age: z.number().min(18, 'Musisz mieć 18 lat'),
})
type UserForm = z.infer<typeof userSchema>
function UserForm() {
const form = useForm({
defaultValues: {
name: '',
email: '',
age: 0,
} as UserForm,
onSubmit: async ({ value }) => {
console.log('Submit:', value)
await saveUser(value)
},
validatorAdapter: zodValidator(),
validators: {
onChange: userSchema,
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.Field
name="name"
children={(field) => (
<div>
<label>Imię</label>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span className="text-red-500">
{field.state.meta.errors.join(', ')}
</span>
)}
</div>
)}
/>
<form.Field
name="email"
children={(field) => (
<div>
<label>Email</label>
<input
type="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span className="text-red-500">
{field.state.meta.errors.join(', ')}
</span>
)}
</div>
)}
/>
<form.Field
name="age"
children={(field) => (
<div>
<label>Wiek</label>
<input
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(Number(e.target.value))}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span className="text-red-500">
{field.state.meta.errors.join(', ')}
</span>
)}
</div>
)}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Zapisywanie...' : 'Zapisz'}
</button>
)}
/>
</form>
)
}Async Validation
<form.Field
name="email"
validators={{
onChangeAsync: async ({ value }) => {
// Debounced async validation
await new Promise(r => setTimeout(r, 300))
const exists = await checkEmailExists(value)
if (exists) {
return 'Ten email jest już zajęty'
}
return undefined
},
onChangeAsyncDebounceMs: 300,
}}
children={(field) => (
<div>
<input {...} />
{field.state.meta.isValidating && <span>Sprawdzanie...</span>}
</div>
)}
/>TanStack Virtual
Dlaczego Virtual?
Renderowanie tysięcy elementów listy zabija wydajność. TanStack Virtual renderuje tylko widoczne elementy.
Instalacja
npm install @tanstack/react-virtualWirtualizowana lista
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Szacowana wysokość elementu
overscan: 5, // Render 5 elementów poza viewport
})
return (
<div
ref={parentRef}
className="h-[400px] overflow-auto"
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
)
}Wirtualizowana tabela
function VirtualTable({ data }: { data: User[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
})
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<table className="w-full">
<thead className="sticky top-0 bg-white">
<tr>
<th>Nazwa</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const user = data[virtualRow.index]
return (
<tr
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.status}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}Porównanie TanStack
| Biblioteka | Problem | Alternatywy |
|---|---|---|
| Query | Server state, cache | SWR, RTK Query |
| Table | Zaawansowane tabele | AG Grid, React Table v7 |
| Router | Type-safe routing | Next.js, React Router |
| Form | Formularze | React Hook Form, Formik |
| Virtual | Długie listy | react-window, react-virtualized |
Best Practices
Query - organizacja kodu
// lib/queries/posts.ts
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
}
export function usePostsQuery(filters: PostFilters) {
return useQuery({
queryKey: postKeys.list(filters),
queryFn: () => fetchPosts(filters),
})
}
export function usePostQuery(id: string) {
return useQuery({
queryKey: postKeys.detail(id),
queryFn: () => fetchPost(id),
enabled: !!id,
})
}
// Invalidacja
queryClient.invalidateQueries({ queryKey: postKeys.all })
queryClient.invalidateQueries({ queryKey: postKeys.lists() })
queryClient.invalidateQueries({ queryKey: postKeys.detail('123') })FAQ
Query vs SWR?
Query ma więcej funkcji (mutations, infinite queries, devtools), SWR jest prostszy.
Czy Table jest za trudne?
Headless = więcej kontroli, ale też więcej kodu. Dla prostych tabel użyj gotowych komponentów.
Czy Router jest lepszy niż React Router?
Dla type-safety - tak. React Router v7 dogania funkcjonalnością.
Kiedy użyć Virtual?
Gdy masz >100 elementów na liście i doświadczasz problemów z wydajnością.
Podsumowanie
TanStack to must-have w nowoczesnym stacku:
- Query - standard dla data fetching
- Table - gdy potrzebujesz zaawansowanych tabel
- Router - gdy type-safety jest priorytetem
- Form - alternatywa dla React Hook Form
- Virtual - dla wydajności z długimi listami
Wszystkie biblioteki są darmowe, open-source i aktywnie rozwijane.
TanStack - Library Ecosystem for Professional Applications
What is TanStack?
TanStack is a collection of professional, framework-agnostic libraries created by Tanner Linsley. Each library solves a specific problem in a type-safe and performant way:
- TanStack Query - Server state management (formerly React Query)
- TanStack Table - Headless table logic
- TanStack Router - Type-safe routing
- TanStack Form - Form management
- TanStack Virtual - Virtualization of long lists
All libraries work with React, Vue, Solid, Svelte, and vanilla JS.
TanStack Query (React Query v5)
Why Query?
The traditional approach to data fetching in React:
// ❌ Classic approach - lots of boilerplate
function Posts() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(setPosts)
.catch(setError)
.finally(() => setLoading(false))
}, [])
// What about: refetching, cache, stale data, deduplication, pagination...?
}With TanStack Query:
// ✅ Elegant, with full cache and state management
function Posts() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
})
// Automatically: cache, refetch, deduplication, background updates!
}Installation
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools # optionalConfiguration
// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
// Data is "fresh" for 60 seconds
staleTime: 60 * 1000,
// Cache kept for 5 minutes
gcTime: 5 * 60 * 1000,
// Retry 3 times on error
retry: 3,
// Refetch on window focus
refetchOnWindowFocus: true,
},
},
}))
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}useQuery - Fetching data
import { useQuery } from '@tanstack/react-query'
// Basic usage
function PostsList() {
const {
data,
isLoading,
isError,
error,
isFetching,
isStale,
refetch,
} = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts')
if (!response.ok) throw new Error('Network error')
return response.json()
},
})
if (isLoading) return <Skeleton />
if (isError) return <Error message={error.message} />
return (
<div>
{/* isFetching = background refetch */}
{isFetching && <RefreshIndicator />}
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<button onClick={() => refetch()}>Refresh</button>
</div>
)
}Query Keys - the key to caching
// Simple keys
useQuery({ queryKey: ['posts'], ... })
useQuery({ queryKey: ['users'], ... })
// Parameterized keys
useQuery({ queryKey: ['post', postId], ... })
useQuery({ queryKey: ['posts', { status: 'published' }], ... })
useQuery({ queryKey: ['user', userId, 'posts'], ... })
// Keys are serialized - order matters!
['posts', { status: 'published', page: 1 }]
// !== ['posts', { page: 1, status: 'published' }]
// But with the option: queryKeyHashFn you can change thisuseQuery with parameters
function PostDetail({ postId }: { postId: string }) {
const { data: post } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
// Don't execute query if there's no ID
enabled: !!postId,
})
return <article>{post?.title}</article>
}
// With filters
function FilteredPosts({ status, page }: { status: string; page: number }) {
const { data } = useQuery({
queryKey: ['posts', { status, page }],
queryFn: () => fetchPosts({ status, page }),
// Keep previous data while loading new ones
placeholderData: keepPreviousData,
})
return <PostList posts={data?.posts} />
}useMutation - Modifying data
import { useMutation, useQueryClient } from '@tanstack/react-query'
function CreatePost() {
const queryClient = useQueryClient()
const createPost = useMutation({
mutationFn: async (newPost: { title: string; content: string }) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
})
if (!response.ok) throw new Error('Failed to create post')
return response.json()
},
// Optimistic update
onMutate: async (newPost) => {
// Cancel active queries
await queryClient.cancelQueries({ queryKey: ['posts'] })
// Snapshot of previous data
const previousPosts = queryClient.getQueryData(['posts'])
// Optimistically add the new post
queryClient.setQueryData(['posts'], (old: Post[]) => [
{ id: 'temp', ...newPost },
...old,
])
return { previousPosts }
},
// On error - rollback
onError: (err, newPost, context) => {
queryClient.setQueryData(['posts'], context?.previousPosts)
},
// After success or error - invalidate
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
})
return (
<form onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
createPost.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
})
}}>
<input name="title" required />
<textarea name="content" required />
<button disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create post'}
</button>
{createPost.isError && <p className="text-red-500">{createPost.error.message}</p>}
</form>
)
}Infinite Queries (Pagination)
import { useInfiniteQuery } from '@tanstack/react-query'
function InfinitePostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts', 'infinite'],
queryFn: async ({ pageParam }) => {
const response = await fetch(`/api/posts?cursor=${pageParam}`)
return response.json()
},
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
})
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading...'
: hasNextPage
? 'Load more'
: 'End of list'}
</button>
</div>
)
}Prefetching and Hydration (Next.js)
// app/posts/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { PostsList } from './posts-list'
export default async function PostsPage() {
const queryClient = new QueryClient()
// Prefetch on the server
await queryClient.prefetchQuery({
queryKey: ['posts'],
queryFn: () => fetch('https://api.example.com/posts').then(r => r.json()),
})
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostsList />
</HydrationBoundary>
)
}
// posts-list.tsx (Client Component)
'use client'
import { useQuery } from '@tanstack/react-query'
export function PostsList() {
// This data is already in cache from the server - instant render!
const { data } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(r => r.json()),
})
return <ul>{data?.map(post => <li key={post.id}>{post.title}</li>)}</ul>
}TanStack Table
Why TanStack Table?
TanStack Table is a headless library - it provides the logic, you control the rendering. It supports:
- Sorting (multi-column)
- Filtering (global and per-column)
- Pagination
- Row selection
- Column resizing/reordering
- Grouping
- Aggregations
Installation
npm install @tanstack/react-tableBasic table
import {
useReactTable,
getCoreRowModel,
flexRender,
createColumnHelper,
} from '@tanstack/react-table'
interface User {
id: string
name: string
email: string
status: 'active' | 'inactive'
createdAt: Date
}
const columnHelper = createColumnHelper<User>()
const columns = [
columnHelper.accessor('name', {
header: 'Name',
cell: info => info.getValue(),
}),
columnHelper.accessor('email', {
header: 'Email',
cell: info => (
<a href={`mailto:${info.getValue()}`} className="text-blue-500">
{info.getValue()}
</a>
),
}),
columnHelper.accessor('status', {
header: 'Status',
cell: info => (
<span className={`px-2 py-1 rounded text-sm ${
info.getValue() === 'active'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{info.getValue()}
</span>
),
}),
columnHelper.accessor('createdAt', {
header: 'Created at',
cell: info => info.getValue().toLocaleDateString('en-US'),
}),
]
function UsersTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}Sorting
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
SortingState,
} from '@tanstack/react-table'
function SortableTable({ data }: { data: User[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="cursor-pointer select-none"
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</th>
))}
</tr>
))}
</thead>
{/* ... */}
</table>
)
}Filtering
import {
useReactTable,
getFilteredRowModel,
ColumnFiltersState,
} from '@tanstack/react-table'
function FilterableTable({ data }: { data: User[] }) {
const [globalFilter, setGlobalFilter] = useState('')
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const table = useReactTable({
data,
columns,
state: { globalFilter, columnFilters },
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
return (
<div>
{/* Global search */}
<input
type="text"
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search..."
className="mb-4 px-4 py-2 border rounded"
/>
{/* Column filter */}
<select
value={(table.getColumn('status')?.getFilterValue() as string) ?? ''}
onChange={e => table.getColumn('status')?.setFilterValue(e.target.value)}
>
<option value="">All statuses</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<table>{/* ... */}</table>
</div>
)
}Pagination
import { getPaginationRowModel } from '@tanstack/react-table'
function PaginatedTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: {
pageSize: 10,
},
},
})
return (
<div>
<table>{/* ... */}</table>
<div className="flex items-center gap-2 mt-4">
<button
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
{'<<'}
</button>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<span>
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<button
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
{'>>'}
</button>
<select
value={table.getState().pagination.pageSize}
onChange={e => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map(pageSize => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</div>
</div>
)
}Row Selection
import { RowSelectionState } from '@tanstack/react-table'
function SelectableTable({ data }: { data: User[] }) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const columns = [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
),
},
// ... other columns
]
const table = useReactTable({
data,
columns,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
return (
<div>
<p>{selectedRows.length} selected</p>
{selectedRows.length > 0 && (
<button onClick={() => deleteSelected(selectedRows)}>
Delete selected
</button>
)}
<table>{/* ... */}</table>
</div>
)
}TanStack Router
Why TanStack Router?
- 100% type-safe - full type inference for params, search, loader data
- File-based routing - optional, like in Next.js
- Nested layouts - with typed outlets
- Search params - validation with Zod
- Loaders - data fetching with TanStack Query integration
Installation
npm install @tanstack/react-routerRoute definitions
// routes/__root.tsx
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => (
<div>
<Header />
<main>
<Outlet />
</main>
<Footer />
</div>
),
})
// routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
return <h1>Home page</h1>
}
// routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/posts/$postId')({
// Loader with full types
loader: async ({ params }) => {
// params.postId is type-safe!
return fetchPost(params.postId)
},
component: PostPage,
})
function PostPage() {
// useLoaderData returns the type from loader!
const post = Route.useLoaderData()
return <article>{post.title}</article>
}Search Params with validation
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
const searchSchema = z.object({
page: z.number().default(1),
filter: z.enum(['all', 'active', 'inactive']).default('all'),
search: z.string().optional(),
})
export const Route = createFileRoute('/users')({
validateSearch: searchSchema,
component: UsersPage,
})
function UsersPage() {
// search is fully typed!
const { page, filter, search } = Route.useSearch()
const navigate = Route.useNavigate()
return (
<div>
<input
value={search ?? ''}
onChange={e => navigate({
search: prev => ({ ...prev, search: e.target.value })
})}
/>
<select
value={filter}
onChange={e => navigate({
search: prev => ({ ...prev, filter: e.target.value })
})}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<Pagination
page={page}
onPageChange={p => navigate({
search: prev => ({ ...prev, page: p })
})}
/>
</div>
)
}Type-safe Navigation
import { Link, useNavigate } from '@tanstack/react-router'
function Navigation() {
const navigate = useNavigate()
return (
<nav>
{/* Link with autocomplete for to, params, search */}
<Link to="/">Home</Link>
<Link to="/posts/$postId" params={{ postId: '123' }}>
Post 123
</Link>
<Link
to="/users"
search={{ page: 1, filter: 'active' }}
>
Active users
</Link>
{/* Programmatic navigation */}
<button onClick={() => navigate({ to: '/dashboard' })}>
Dashboard
</button>
</nav>
)
}TanStack Form
Why TanStack Form?
- Headless - full control over UI
- Type-safe - full inference for form values and errors
- Async validation - with debounce
- Field arrays - dynamic fields
- Validation adapters - Zod, Yup, Valibot
Installation
npm install @tanstack/react-form @tanstack/zod-form-adapter zodBasic form
import { useForm } from '@tanstack/react-form'
import { zodValidator } from '@tanstack/zod-form-adapter'
import { z } from 'zod'
const userSchema = z.object({
name: z.string().min(2, 'Minimum 2 characters'),
email: z.string().email('Invalid email'),
age: z.number().min(18, 'You must be 18 years old'),
})
type UserForm = z.infer<typeof userSchema>
function UserForm() {
const form = useForm({
defaultValues: {
name: '',
email: '',
age: 0,
} as UserForm,
onSubmit: async ({ value }) => {
console.log('Submit:', value)
await saveUser(value)
},
validatorAdapter: zodValidator(),
validators: {
onChange: userSchema,
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<form.Field
name="name"
children={(field) => (
<div>
<label>Name</label>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span className="text-red-500">
{field.state.meta.errors.join(', ')}
</span>
)}
</div>
)}
/>
<form.Field
name="email"
children={(field) => (
<div>
<label>Email</label>
<input
type="email"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span className="text-red-500">
{field.state.meta.errors.join(', ')}
</span>
)}
</div>
)}
/>
<form.Field
name="age"
children={(field) => (
<div>
<label>Age</label>
<input
type="number"
value={field.state.value}
onChange={(e) => field.handleChange(Number(e.target.value))}
onBlur={field.handleBlur}
/>
{field.state.meta.errors && (
<span className="text-red-500">
{field.state.meta.errors.join(', ')}
</span>
)}
</div>
)}
/>
<form.Subscribe
selector={(state) => [state.canSubmit, state.isSubmitting]}
children={([canSubmit, isSubmitting]) => (
<button type="submit" disabled={!canSubmit || isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</button>
)}
/>
</form>
)
}Async Validation
<form.Field
name="email"
validators={{
onChangeAsync: async ({ value }) => {
// Debounced async validation
await new Promise(r => setTimeout(r, 300))
const exists = await checkEmailExists(value)
if (exists) {
return 'This email is already taken'
}
return undefined
},
onChangeAsyncDebounceMs: 300,
}}
children={(field) => (
<div>
<input {...} />
{field.state.meta.isValidating && <span>Checking...</span>}
</div>
)}
/>TanStack Virtual
Why Virtual?
Rendering thousands of list items kills performance. TanStack Virtual renders only the visible elements.
Installation
npm install @tanstack/react-virtualVirtualized list
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
function VirtualList({ items }: { items: string[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // Estimated item height
overscan: 5, // Render 5 items outside the viewport
})
return (
<div
ref={parentRef}
className="h-[400px] overflow-auto"
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
)
}Virtualized table
function VirtualTable({ data }: { data: User[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
})
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<table className="w-full">
<thead className="sticky top-0 bg-white">
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
</tr>
</thead>
<tbody
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const user = data[virtualRow.index]
return (
<tr
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.status}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}TanStack comparison
| Library | Problem | Alternatives |
|---|---|---|
| Query | Server state, cache | SWR, RTK Query |
| Table | Advanced tables | AG Grid, React Table v7 |
| Router | Type-safe routing | Next.js, React Router |
| Form | Forms | React Hook Form, Formik |
| Virtual | Long lists | react-window, react-virtualized |
Best Practices
Query - code organization
// lib/queries/posts.ts
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters: PostFilters) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, 'detail'] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
}
export function usePostsQuery(filters: PostFilters) {
return useQuery({
queryKey: postKeys.list(filters),
queryFn: () => fetchPosts(filters),
})
}
export function usePostQuery(id: string) {
return useQuery({
queryKey: postKeys.detail(id),
queryFn: () => fetchPost(id),
enabled: !!id,
})
}
// Invalidation
queryClient.invalidateQueries({ queryKey: postKeys.all })
queryClient.invalidateQueries({ queryKey: postKeys.lists() })
queryClient.invalidateQueries({ queryKey: postKeys.detail('123') })FAQ
Query vs SWR?
Query has more features (mutations, infinite queries, devtools), SWR is simpler.
Is Table too difficult?
Headless = more control, but also more code. For simple tables, use ready-made components.
Is Router better than React Router?
For type-safety - yes. React Router v7 is catching up in functionality.
When to use Virtual?
When you have >100 items on a list and are experiencing performance issues.
Summary
TanStack is a must-have in a modern stack:
- Query - the standard for data fetching
- Table - when you need advanced tables
- Router - when type-safety is a priority
- Form - an alternative to React Hook Form
- Virtual - for performance with long lists
All libraries are free, open-source, and actively maintained.