Przejdź do treści

Zaawansowane tabele danych z TanStack Table w Next.js

Jak zbudować w Next.js tabelę z TanStack Table v9: type-safe kolumny, sortowanie, filtry, paginację, selekcję oraz tryb serwerowy ze stanem w URL.

Maciej Sala

Founder StriveLab

5 min czytaniaAktualizacja

Instalacja TanStack Table v9

Code
npm install @tanstack/react-table

Ten artykuł używa stabilnego API TanStack Table v9. Kod z v8 oparty na useReactTable() i getPaginationRowModel() wymaga migracji albo świadomego użycia warstwy legacy. W v9 podstawą są useTable(), tableFeatures() oraz fabryki modeli takie jak createPaginatedRowModel().

Model danych i type-safe kolumny

Zacznij od DTO niezależnego od modelu ORM. Kwotę przechowujemy jako liczbę całkowitą w groszach, dzięki czemu kod klienta nie wykonuje działań na liczbach zmiennoprzecinkowych. Datę przekazujemy jako ISO string, ponieważ propsy przekraczające granicę Server/Client Component muszą być serializowalne.

Code
// app/dashboard/orders/order-types.ts
export type OrderStatus =
  | 'pending'
  | 'paid'
  | 'shipped'
  | 'delivered'
  | 'cancelled'
 
export interface OrderDto {
  id: string
  customerName: string
  email: string
  totalMinor: number
  status: OrderStatus
  createdAt: string
}

Konfigurację funkcji i kolumn trzymaj po stronie klienta. createColumnHelper zna typ wartości każdego accessora, dlatego info.getValue() dla totalMinor ma typ number, a dla status właściwą unię stringów.

Code
// app/dashboard/orders/order-table-config.tsx
'use client'
 
import Link from 'next/link'
import {
  columnFilteringFeature,
  createColumnHelper,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  globalFilteringFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_basic,
  sortFn_text,
  tableFeatures,
} from '@tanstack/react-table'
import { useEffect, useRef, type ComponentPropsWithoutRef } from 'react'
import type { OrderDto, OrderStatus } from './order-types'
 
export const orderTableFeatures = tableFeatures({
  columnFilteringFeature,
  globalFilteringFeature,
  rowSortingFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  filteredRowModel: createFilteredRowModel(),
  sortedRowModel: createSortedRowModel(),
  paginatedRowModel: createPaginatedRowModel(),
  filterFns: { includesString: filterFn_includesString },
  sortFns: { basic: sortFn_basic, text: sortFn_text },
})
 
const columnHelper = createColumnHelper<typeof orderTableFeatures, OrderDto>()
 
const moneyFormatter = new Intl.NumberFormat('pl-PL', {
  style: 'currency',
  currency: 'PLN',
})
 
const dateFormatter = new Intl.DateTimeFormat('pl-PL', {
  dateStyle: 'medium',
  timeZone: 'Europe/Warsaw',
})
 
const statusLabels: Record<OrderStatus, string> = {
  pending: 'Oczekujące',
  paid: 'Opłacone',
  shipped: 'Wysłane',
  delivered: 'Dostarczone',
  cancelled: 'Anulowane',
}
 
function SelectionCheckbox({
  indeterminate = false,
  ...props
}: ComponentPropsWithoutRef<'input'> & { indeterminate?: boolean }) {
  const ref = useRef<HTMLInputElement>(null)
 
  useEffect(() => {
    if (ref.current) {
      ref.current.indeterminate = !props.checked && indeterminate
    }
  }, [indeterminate, props.checked])
 
  return <input ref={ref} type="checkbox" {...props} />
}
 
export const orderColumns = columnHelper.columns([
  columnHelper.display({
    id: 'select',
    header: ({ table }) => (
      <SelectionCheckbox
        aria-label="Zaznacz wiersze na tej stronie"
        checked={table.getIsAllPageRowsSelected()}
        indeterminate={table.getIsSomePageRowsSelected()}
        onChange={table.getToggleAllPageRowsSelectedHandler()}
      />
    ),
    cell: ({ row }) => (
      <SelectionCheckbox
        aria-label={`Zaznacz zamówienie ${row.original.id}`}
        checked={row.getIsSelected()}
        disabled={!row.getCanSelect()}
        onChange={row.getToggleSelectedHandler()}
      />
    ),
  }),
  columnHelper.accessor((order) => `${order.customerName} ${order.email}`, {
    id: 'customer',
    header: 'Klient',
    enableSorting: false,
    cell: ({ row }) => (
      <div>
        <p className="font-medium">{row.original.customerName}</p>
        <p className="text-xs text-gray-500">{row.original.email}</p>
      </div>
    ),
  }),
  columnHelper.accessor('totalMinor', {
    header: 'Kwota',
    sortFn: 'basic',
    cell: (info) => moneyFormatter.format(info.getValue() / 100),
  }),
  columnHelper.accessor('status', {
    header: 'Status',
    enableSorting: false,
    enableGlobalFilter: false,
    filterFn: (row, columnId, value) => row.getValue(columnId) === value,
    cell: (info) => statusLabels[info.getValue()],
  }),
  columnHelper.accessor('createdAt', {
    header: 'Data',
    sortFn: 'basic',
    cell: (info) => dateFormatter.format(new Date(info.getValue())),
  }),
  columnHelper.display({
    id: 'actions',
    header: 'Akcje',
    cell: ({ row }) => (
      <Link href={`/dashboard/orders/${row.original.id}`}>Szczegóły</Link>
    ),
  }),
])

Accessor klienta łączy nazwę i e-mail, więc globalny filtr faktycznie przeszukuje obie wartości. Formatery i mapa statusów powstają raz na poziomie modułu. Jawna strefa czasowa usuwa zależność wyniku od konfiguracji serwera i przeglądarki.

Tabela klientowa: sortowanie, filtry i paginacja

Wariant klientowy ma sens, gdy przeglądarka otrzymuje cały zbiór przeznaczony do przeszukiwania. Wszystkie operacje muszą działać na tym samym zestawie danych. Nie pobieraj jednej strony z API, by później lokalnie ją sortować i przedstawiać jako sortowanie całej tabeli.

Code
// app/dashboard/orders/orders-table.tsx
'use client'
 
import {
  useTable,
  type ColumnFiltersState,
  type RowSelectionState,
  type SortingState,
} from '@tanstack/react-table'
import { useId, useState } from 'react'
import { orderColumns, orderTableFeatures } from './order-table-config'
import type { OrderDto } from './order-types'
 
export function OrdersTable({ data }: { data: OrderDto[] }) {
  const [sorting, setSorting] = useState<SortingState>([
    { id: 'createdAt', desc: true },
  ])
  const [globalFilter, setGlobalFilter] = useState('')
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
  const searchId = useId()
  const statusId = useId()
 
  const table = useTable({
    key: 'orders',
    features: orderTableFeatures,
    data,
    columns: orderColumns,
    getRowId: (order) => order.id,
    globalFilterFn: 'includesString',
    state: { sorting, globalFilter, columnFilters, rowSelection },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    onColumnFiltersChange: setColumnFilters,
    onRowSelectionChange: setRowSelection,
    initialState: { pagination: { pageIndex: 0, pageSize: 20 } },
  })
 
  const statusColumn = table.getColumn('status')
  const filteredCount = table.getFilteredRowModel().rows.length
 
  return (
    <div>
      <div className="mb-4 flex flex-wrap gap-3">
        <div>
          <label htmlFor={searchId} className="sr-only">
            Szukaj w zamówieniach
          </label>
          <input
            id={searchId}
            value={globalFilter}
            onChange={(event) => {
              table.setPageIndex(0)
              table.setGlobalFilter(event.target.value)
            }}
            placeholder="Nazwa klienta lub e-mail"
          />
        </div>
 
        <div>
          <label htmlFor={statusId} className="sr-only">
            Status zamówienia
          </label>
          <select
            id={statusId}
            value={(statusColumn?.getFilterValue() as string | undefined) ?? ''}
            onChange={(event) => {
              table.setPageIndex(0)
              statusColumn?.setFilterValue(event.target.value || undefined)
            }}
          >
            <option value="">Wszystkie statusy</option>
            <option value="pending">Oczekujące</option>
            <option value="paid">Opłacone</option>
            <option value="shipped">Wysłane</option>
            <option value="delivered">Dostarczone</option>
            <option value="cancelled">Anulowane</option>
          </select>
        </div>
      </div>
 
      <div className="overflow-x-auto rounded border">
        <table className="w-full">
          <caption className="sr-only">Lista zamówień</caption>
          <thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  const sorted = header.column.getIsSorted()
                  const canSort = header.column.getCanSort()
 
                  return (
                    <th
                      key={header.id}
                      scope="col"
                      aria-sort={
                        sorted === 'asc'
                          ? 'ascending'
                          : sorted === 'desc'
                            ? 'descending'
                            : canSort
                              ? 'none'
                              : undefined
                      }
                    >
                      {header.isPlaceholder ? null : canSort ? (
                        <button
                          type="button"
                          onClick={header.column.getToggleSortingHandler()}
                        >
                          <table.FlexRender header={header} />
                          <span aria-hidden="true">
                            {sorted === 'asc'
                              ? ' ↑'
                              : sorted === 'desc'
                                ? ' ↓'
                                : ' ↕'}
                          </span>
                        </button>
                      ) : (
                        <table.FlexRender header={header} />
                      )}
                    </th>
                  )
                })}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} data-selected={row.getIsSelected() || undefined}>
                {row.getAllCells().map((cell) => (
                  <td key={cell.id}>
                    <table.FlexRender cell={cell} />
                  </td>
                ))}
              </tr>
            ))}
            {table.getRowModel().rows.length === 0 && (
              <tr>
                <td colSpan={orderColumns.length}>Brak wyników</td>
              </tr>
            )}
          </tbody>
        </table>
      </div>
 
      <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
        <p aria-live="polite">
          Strona {table.state.pagination.pageIndex + 1} z {table.getPageCount()}
          ; {filteredCount} wyników; zaznaczono{' '}
          {table.getFilteredSelectedRowModel().rows.length}
        </p>
        <div className="flex items-center gap-2">
          <select
            aria-label="Liczba wierszy na stronie"
            value={table.state.pagination.pageSize}
            onChange={(event) => table.setPageSize(Number(event.target.value))}
          >
            {[10, 20, 50].map((size) => (
              <option key={size} value={size}>
                {size} / strona
              </option>
            ))}
          </select>
          <button
            type="button"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            Poprzednia
          </button>
          <button
            type="button"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            Następna
          </button>
        </div>
      </div>
    </div>
  )
}

Kontrolujemy tylko stan potrzebny poza wewnętrznymi mechanizmami tabeli. Paginacja może pozostać stanem wewnętrznym, dlatego ustawiamy ją wyłącznie w initialState. W v9 reaktywny odczyt odbywa się przez table.state.pagination, a nie przez znane z v8 table.getState().

Dane z Server Component

Server Component powinien wykonać autoryzację, pobrać tylko dozwolone pola i znormalizować typy ORM. Ukrycie e-maila przez CSS lub mechanizm widoczności kolumn nie usuwa go z payloadu RSC. Szczególnej uwagi wymaga Prisma.Decimal: konwersja Number(order.total) może utracić precyzję.

Code
// app/dashboard/orders/order-dto.ts
import type { Prisma } from '@prisma/client'
 
export function toMinorUnits(value: Prisma.Decimal) {
  const minor = value.mul(100)
  if (!minor.isInteger() || minor.abs().greaterThan(Number.MAX_SAFE_INTEGER)) {
    throw new Error('Kwota nie mieści się w bezpiecznym DTO')
  }
  return minor.toNumber()
}

Mapper toOrderDto() powinien dodatkowo skopiować wybrane pola, użyć toMinorUnits(order.total) i zamienić datę przez toISOString(). Jego typ wejściowy wyprowadź z walidowanego select Prisma, aby mapper i zapytanie zawsze miały ten sam kontrakt.

Tryb serwerowy: paginacja i filtry w URL

Jeżeli klient otrzymuje tylko jedną stronę, baza musi wykonać filtrowanie, sortowanie i paginację w tej kolejności. Parametr sortowania mapuj przez whitelistę. Nigdy nie składaj nazwy pola ORM bezpośrednio z wartości przesłanej w URL.

Poniższy fragment zakłada PostgreSQL. mode: 'insensitive' jest zależne od providera Prisma; w MySQL zwykle decyduje collation i ta właściwość nie jest dostępna.

Code
// app/dashboard/orders/page.tsx - wariant serwerowy
import type { Prisma } from '@prisma/client'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { requireOrdersPermission } from '@/lib/authz'
import { ServerOrdersTable } from './server-orders-table'
import { toOrderDto } from './order-dto'
import type { OrderStatus } from './order-types'
 
const PAGE_SIZE = 20
const DEFAULT_SORT = 'createdAt.desc'
 
const SORT_MAP = {
  'createdAt.desc': [{ createdAt: 'desc' }, { id: 'desc' }],
  'createdAt.asc': [{ createdAt: 'asc' }, { id: 'asc' }],
  'totalMinor.desc': [{ total: 'desc' }, { id: 'desc' }],
  'totalMinor.asc': [{ total: 'asc' }, { id: 'asc' }],
} satisfies Record<string, Prisma.OrderOrderByWithRelationInput[]>
 
type SortKey = keyof typeof SORT_MAP
type SearchParams = Promise<Record<string, string | string[] | undefined>>
const ORDER_STATUSES: readonly OrderStatus[] = [
  'pending',
  'paid',
  'shipped',
  'delivered',
  'cancelled',
]
 
function single(value: string | string[] | undefined) {
  return typeof value === 'string' ? value : undefined
}
 
function parsePage(value: string | undefined) {
  if (!value || !/^\d+$/.test(value)) return 1
  const page = Number(value)
  return Number.isSafeInteger(page) && page > 0 ? page : 1
}
 
function parseSort(value: string | undefined): SortKey {
  return value && value in SORT_MAP ? (value as SortKey) : DEFAULT_SORT
}
 
function parseStatus(value: string | undefined): OrderStatus | undefined {
  return ORDER_STATUSES.includes(value as OrderStatus)
    ? (value as OrderStatus)
    : undefined
}
 
export default async function OrdersPage({
  searchParams,
}: {
  searchParams: SearchParams
}) {
  await requireOrdersPermission()
 
  const raw = await searchParams
  const requestedPage = parsePage(single(raw.page))
  const sort = parseSort(single(raw.sort))
  const filter = single(raw.filter)?.trim().slice(0, 100) ?? ''
  const status = parseStatus(single(raw.status))
 
  const where = {
    ...(filter
      ? {
          OR: [
            {
              customerName: { contains: filter, mode: 'insensitive' as const },
            },
            { email: { contains: filter, mode: 'insensitive' as const } },
          ],
        }
      : {}),
    ...(status ? { status } : {}),
  } satisfies Prisma.OrderWhereInput
 
  const rowCount = await db.order.count({ where })
  const pageCount = Math.max(1, Math.ceil(rowCount / PAGE_SIZE))
 
  if (requestedPage > pageCount) {
    const canonical = new URLSearchParams()
    if (pageCount > 1) canonical.set('page', String(pageCount))
    if (sort !== DEFAULT_SORT) canonical.set('sort', sort)
    if (filter) canonical.set('filter', filter)
    if (status) canonical.set('status', status)
    const query = canonical.toString()
    redirect(`/dashboard/orders${query ? `?${query}` : ''}`)
  }
 
  const records = await db.order.findMany({
    where,
    select: {
      id: true,
      customerName: true,
      email: true,
      total: true,
      status: true,
      createdAt: true,
    },
    skip: (requestedPage - 1) * PAGE_SIZE,
    take: PAGE_SIZE,
    orderBy: SORT_MAP[sort],
  })
 
  return (
    <ServerOrdersTable
      data={records.map(toOrderDto)}
      rowCount={rowCount}
      pageIndex={requestedPage - 1}
      pageSize={PAGE_SIZE}
      sorting={[
        {
          id: sort.split('.')[0],
          desc: sort.endsWith('.desc'),
        },
      ]}
      globalFilter={filter}
      status={status ?? ''}
    />
  )
}

toOrderDto() może używać tego samego sprawdzonego mapowania kwoty i daty co wcześniejszy wariant. searchParams w stronie App Routera jest obietnicą, a jego odczyt powoduje dynamiczne renderowanie trasy. To oczekiwane dla widoku zależnego od zapytania do bazy.

Po stronie klienta pozostaw ten sam renderer nagłówków i wierszy. Zmień wyłącznie źródło stanu i konfigurację useTable. Helper replaceQuery() klonuje window.location.search, nanosi przekazane zmiany i wywołuje router.replace(..., { scroll: false }); dzięki temu równoległa zmiana filtra nie nadpisuje nowszego sortowania.

Code
const pagination = { pageIndex: props.pageIndex, pageSize: props.pageSize }
 
const table = useTable({
  key: 'server-orders',
  features: orderTableFeatures,
  data: props.data,
  columns: orderColumns,
  getRowId: (order) => order.id,
  rowCount: props.rowCount,
  manualFiltering: true,
  manualSorting: true,
  manualPagination: true,
  enableMultiSort: false,
  enableSortingRemoval: false,
  state: {
    pagination,
    sorting: props.sorting,
    globalFilter: props.globalFilter,
  },
  onPaginationChange: (updater) => {
    const next = typeof updater === 'function' ? updater(pagination) : updater
    replaceQuery({
      page: next.pageIndex > 0 ? String(next.pageIndex + 1) : null,
    })
  },
  onSortingChange: (updater) => {
    const next =
      typeof updater === 'function' ? updater(props.sorting) : updater
    const first = next[0] ?? props.sorting[0]
    replaceQuery({
      page: null,
      sort: `${first.id}.${first.desc ? 'desc' : 'asc'}`,
    })
  },
})

manualFiltering, manualSorting i manualPagination nie wykonują zapytania. Informują bibliotekę, że przekazane wiersze są już przetworzone. Input wyszukiwarki może mieć lokalny stan i po 300 ms wywoływać replaceQuery({ page: null, filter }); props globalFilter powinien synchronizować go po nawigacji wstecz lub dalej. Reset page przy zmianie filtra lub sortowania jest obowiązkiem aplikacji. enableSortingRemoval: false zapobiega stanowi, w którym UI usuwa sortowanie, a backend natychmiast przywraca domyślny porządek.

W kodzie produkcyjnym wydziel sam markup do wspólnego komponentu renderującego instancję tabeli. Nie duplikuj ponad stu linii <thead>, <tbody> i paginacji tylko dlatego, że zmieniło się źródło danych.

Stabilność, wydajność i bezpieczeństwo zapytań

Przy paginacji offsetowej dodaj unikalny klucz rozstrzygający remisy. Sortowanie wyłącznie po dacie albo kwocie może zmieniać kolejność rekordów między zapytaniami. W przykładzie każda pozycja SORT_MAP kończy się sortowaniem po id w tym samym kierunku.

Duży skip jest kosztowny. Jeżeli użytkownik przechodzi głównie do następnej i poprzedniej partii, rozważ paginację kursorową. Dokładne count() również może być drogie; czasem wystarczy pobrać PAGE_SIZE + 1 rekordów i poinformować, czy istnieje następna strona.

Zapytanie count() i późniejsze findMany() mogą zobaczyć nieco inny stan przy równoległych zapisach. Jeżeli dokładna spójność liczby i strony jest wymaganiem biznesowym, użyj transakcji z odpowiednim poziomem izolacji i przygotuj obsługę konfliktów. Nie podnoś izolacji automatycznie dla każdej tabeli, bo ma to koszt.

Indeksy dobieraj na podstawie planów zapytań. Dla wyszukiwania PostgreSQL przez contains samo zwykłe B-tree zwykle nie wystarczy; przy dużych tabelach warto rozważyć indeksy trigramowe albo wyszukiwarkę przeznaczoną do tego zadania.

Dostępność, responsywność i wirtualizacja

Semantyczne <table>, <th scope="col"> i <caption> zachowują relacje między danymi. Sortowanie umieszczaj w prawdziwym <button> i wystawiaj przez aria-sort. Sam onClick na <th> nie zapewnia poprawnej obsługi klawiatury.

Checkbox częściowego zaznaczenia wymaga dwóch warstw: informacji dla technologii asystujących oraz właściwości DOM indeterminate dla prezentacji wizualnej. Komponent SelectionCheckbox ustawia natywną właściwość przez ref zamiast udawać stan wyłącznie ikoną.

Na małym ekranie zacznij od kontenera overflow-x-auto. Ukrywanie kolumn może poprawić ergonomię, ale nie może ukrywać danych poufnych, które nie powinny znaleźć się w przeglądarce.

TanStack Table nie wirtualizuje DOM samodzielnie. Przy setkach widocznych wierszy połącz go z TanStack Virtual. Wirtualizacja nie zastępuje paginacji serwerowej: ogranicza liczbę elementów DOM, ale nie zmniejsza automatycznie odpowiedzi z bazy.

Co testować

  • Kierunek sortowania i zgodność identyfikatorów kolumn z whitelistą backendu.

  • Reset strony po zmianie filtra, statusu, sortowania i rozmiaru strony.

  • Parametry puste, powtórzone, spoza whitelisty i numer strony poza zakresem.

  • Pusty wynik, stan ładowania oraz nawigację wstecz i dalej.

  • Sortowanie klawiaturą, nazwę dostępną checkboxów i stan indeterminate.

  • Brak dostępu do strony i operacji masowych bez wymaganych uprawnień.

Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

TanStack Table, AG Grid czy MUI DataGrid?

TanStack Table jest biblioteką headless: dostarcza stan i logikę tabeli, ale nie narzuca komponentów ani stylów. Dobrze pasuje do własnego design systemu. AG Grid oferuje więcej gotowych funkcji klasy enterprise, a MUI DataGrid jest naturalnym wyborem w aplikacji opartej na Material UI.

Czy mogę eksportować dane tabeli do CSV?

Tak. W TanStack Table v9 table.getPrePaginatedRowModel().rows zwraca wiersze po filtrowaniu i sortowaniu, ale przed paginacją. W trybie serwerowym tabela zna tylko pobraną stronę, dlatego eksport całego wyniku powinien realizować osobny endpoint z tymi samymi filtrami i autoryzacją.

Jak obsłużyć tabelę na urządzeniach mobilnych?

Najprostszy poprawny wariant to semantyczna tabela w kontenerze z overflow-x-auto. Można też ukrywać mniej istotne kolumny albo przygotować osobny widok listy, zachowując dostępność danych.

Kiedy przejść z paginacji klienckiej na serwerową?

Nie istnieje uniwersalny próg rekordów. Uwzględnij rozmiar odpowiedzi, liczbę kolumn, koszt zapytania, pamięć urządzeń docelowych i czas operacji. Tryb serwerowy jest potrzebny, gdy klient nie powinien pobierać całego zbioru albo operacje muszą obejmować dane spoza bieżącej strony.

Dlaczego dane pobierać w Server Component, a tabelę renderować w Client Component?

Server Component może odczytać bazę bez dodatkowego Route Handlera. Interaktywna tabela pozostaje Client Componentem. Do klienta należy przekazywać tylko serializowalne DTO, a definicje kolumn zawierające funkcje importować bezpośrednio w komponencie klienckim.

O autorze

Maciej Sala

Maciej Sala — konsultant technologiczny produktów cyfrowych i web developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną
LH.pl – Hosting Mango

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Pobieranie danych w React: React Query vs SWR vs RSC

Data fetching , czyli sztuka sensownego pobierania danych, to bodajże najstarszy problem w świecie Reacta. Napisanie podstawowego useEffect wraz z fetch jest proste, ale dopisanie do niego logiki, która poprawnie obsłuży cache, usunie zdublowane zapytania, bezbłędnie zadba o powtórzenia w razie błędów retry i elegancko wybroni się przed nadpisywaniem starych wyników race conditions ... to już grubszy temat.

Maciej Sala

Maciej Sala

Founder StriveLab

Wyszukiwarka z filtrami w Next.js — searchParams, debounce, URL state i UX

Użytkownik ustawia trzy filtry, znajduje idealny wynik, wysyła link koledze — a tamten widzi pustą wyszukiwarkę. Bo filtry siedziały w useState i zginęły poza jego przeglądarką. URL state rozwiązuje to u źródła: cały stan filtrów żyje w adresie, więc przetrwa odświeżenie, udostępnienie i przycisk „wstecz”, a do tego jest czytelny dla Server Components. Pokazuję, jak zbudować taką wyszukiwarkę — z debounce , skeletonami, walidacją parametrów i SEO.

Maciej Sala

Maciej Sala

Founder StriveLab

Backend dla frontendowca: serwer, bazy danych i API

Frontend rzadko kończy się na komponencie i jednym fetch , a im bliżej realnego produktu, tym częściej jakość UI zależy od zachowania backendu. Jak API paginuje dane, jak zwraca błędy, jak kontroluje dostęp, co robi po przekroczeniu limitu czasu i czy potrafi bezpiecznie przyjąć ponowione żądanie.

Maciej Sala

Maciej Sala

Founder StriveLab

LH.pl – Cloud Server 1C4G