Usamos cookies para mejorar tu experiencia en el sitio
CodeWorlds
Volver a colecciones
Guide29 min read

TanStack

TanStack is an ecosystem of powerful TypeScript libraries - Query for server state management, Table for tables, Router for routing, Form for forms and Virtual for list virtualization.

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:

Code
TypeScript
// ❌ 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:

Code
TypeScript
// ✅ 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

Code
Bash
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools  # opcjonalne

Konfiguracja

TSapp/providers.tsx
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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)

Code
TypeScript
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)

TSapp/posts/page.tsx
TypeScript
// 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

Code
Bash
npm install @tanstack/react-table

Podstawowa tabela

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
npm install @tanstack/react-router

Definicja Routes

TSroutes/__root.tsx
TypeScript
// 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ą

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
npm install @tanstack/react-form @tanstack/zod-form-adapter zod

Podstawowy formularz

Code
TypeScript
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

Code
TypeScript
<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

Code
Bash
npm install @tanstack/react-virtual

Wirtualizowana lista

Code
TypeScript
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

Code
TypeScript
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

BibliotekaProblemAlternatywy
QueryServer state, cacheSWR, RTK Query
TableZaawansowane tabeleAG Grid, React Table v7
RouterType-safe routingNext.js, React Router
FormFormularzeReact Hook Form, Formik
VirtualDługie listyreact-window, react-virtualized

Best Practices

Query - organizacja kodu

TSlib/queries/posts.ts
TypeScript
// 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:

Code
TypeScript
// ❌ 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:

Code
TypeScript
// ✅ 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

Code
Bash
npm install @tanstack/react-query
npm install @tanstack/react-query-devtools  # optional

Configuration

TSapp/providers.tsx
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
// 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 this

useQuery with parameters

Code
TypeScript
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

Code
TypeScript
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)

Code
TypeScript
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)

TSapp/posts/page.tsx
TypeScript
// 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

Code
Bash
npm install @tanstack/react-table

Basic table

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
npm install @tanstack/react-router

Route definitions

TSroutes/__root.tsx
TypeScript
// 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

Code
TypeScript
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

Code
TypeScript
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

Code
Bash
npm install @tanstack/react-form @tanstack/zod-form-adapter zod

Basic form

Code
TypeScript
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

Code
TypeScript
<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

Code
Bash
npm install @tanstack/react-virtual

Virtualized list

Code
TypeScript
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

Code
TypeScript
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

LibraryProblemAlternatives
QueryServer state, cacheSWR, RTK Query
TableAdvanced tablesAG Grid, React Table v7
RouterType-safe routingNext.js, React Router
FormFormsReact Hook Form, Formik
VirtualLong listsreact-window, react-virtualized

Best Practices

Query - code organization

TSlib/queries/posts.ts
TypeScript
// 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.