eduardweb.
shadcn/uiAvansat#nextjs#typescript#react#shadcn-ui#tanstack-table

shadcn/ui DataTable cu TanStack Table: Paginare și filtrare server-side în Next.js

De Ana Ionescu, 8 aug. 2026 · 10 vizualizări · 3 like-uri

Postat 8 aug. 2026
typescript
'use client'

import * as React from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'

interface DataTableProps<TData> {
  data: TData[]
  pageCount: number
}

export function ServerDataTable<TData>({ data, pageCount }: DataTableProps<TData>) {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const [isPending, startTransition] = React.useTransition()

  const page = Number(searchParams.get('page')) || 1
  const perPage = Number(searchParams.get('per_page')) || 10

  const createQueryString = React.useCallback(
    (params: Record<string, string | number | null>) => {
      const newSearchParams = new URLSearchParams(searchParams.toString())
      for (const [key, value] of Object.entries(params)) {
        if (value === null) newSearchParams.delete(key)
        else newSearchParams.set(key, String(value))
      }
      return newSearchParams.toString()
    },
    [searchParams]
  )

  const table = useReactTable({
    data,
    pageCount,
    manualPagination: true,
    state: {
      pagination: { pageIndex: page - 1, pageSize: perPage },
    },
    onPaginationChange: (updater) => {
      const nextState = typeof updater === 'function' 
        ? updater({ pageIndex: page - 1, pageSize: perPage }) 
        : updater
      
      startTransition(() => {
        const queryString = createQueryString({
          page: nextState.pageIndex + 1,
          per_page: nextState.pageSize,
        })
        router.push(`${pathname}?${queryString}`)
      })
    },
    getCoreRowModel: getCoreRowModel(),
  })

  return (
    <div className={isPending ? 'opacity-50 pointer-events-none transition-opacity' : ''}>
      {/* Aici randezi Table layout din shadcn/ui folosind instance-ul table */}
    </div>
  )
}

Am văzut prea multe tutoriale în care shadcn/ui DataTable e configurat pur client-side. Arată spectaculos în demo-uri cu 10 mock-uri, dar când arunci 100k de rânduri din PostgreSQL peste el, browserul intră direct în comă. Haideți să vedem cum legăm TanStack Table v8 direct la URL în Next.js App Router, astfel încât paginarea, filtrarea și sortarea să ruleze server-side fără să ne complicăm viața.

Capcana client-side-ului și sursa unică de adevăr

La un proiect recent de fintech, aveam de afișat un tabel cu peste 120k de loguri de audit. Implementarea veche încărca tot JSON-ul în browser și lăsa TanStack Table să facă filtering și pagination în client. Rezultatul? Un initial payload de 14MB și în jur de 3-4 secunde de lag la fiecare tasta apăsată în search bar.

Rezolvarea curată presupune mutarea întregii stări (page, per_page, sort, search) direct în query params (?page=2&sort=created_at.desc). În felul ăsta, URL-ul devine sursa unică de adevăr. Utilizatorul poate da bookmark, share la link sau refresh, iar aplicația își păstrează starea exactă. În plus, Server Component-ul părinte preia parametrii, interoghează baza de date și trimite înapoi doar cele 10-20 de rânduri necesare.

Cum sincronizezi TanStack cu Next.js fără re-renders masive

Secretul stă în deconectarea stării interne din @tanstack/react-table. În loc să lăsăm componenta să-și gestioneze pagination sau sorting local, îi activăm flag-urile manuale: manualPagination: true, manualSorting: true și manualFiltering: true.

Când utilizatorul schimbă pagina sau apasă pe o coloană pentru sortare, nu modificăm un useState local. În schimb, interceptăm evenimentul și actualizăm query params folosind useRouter sau usePathname. Un truc esențial aici este împachetarea navigării într-un useTransition. Aceasta previne blocarea interfeței și îți oferă un flag isPending gratuit pentru a afișa un loading state discret peste tabel.

Trade-off-uri reale: de ce nu e totul roz

Marele câștig este performanța brută. Treci de la megabiți de date descărcate la un payload infim de câțiva kilobiți per pagină, iar memoria RAM din browser rămâne intactă.

Totuși, există și minusuri pe care trebuie să le iei în calcul. În primul rând, pierzi debounce-ul out-of-the-box pe search. Dacă cineva tastează rapid într-un input și tu declanșezi o navigare la fiecare keystroke, îți vei omorî baza de date cu query-uri Prisma sau Drizzle. Soluția e să folosești un local state scurt pentru input, cu un useDebounce de 300-400ms înainte să împingi noua valoare în URL.

În al doilea rând, UX-ul pentru erori e ceva mai delicat. Dacă serverul crapă în timpul unei re-evaluări de query, trebuie să ai un error.tsx bine pus la punct în folderul rutei, altfel tabelul rămâne într-o stare nedeterminată.

Regula mea de aur? Dacă ai sub 500-1000 de iteme în total, rămâi pe client-side state. E mai simplu și instant. Dacă depășești pragul ăsta sau ai cerințe de SEO și sharable links, server-side URL state devine obligatoriu.

Voi ce abordare folosiți pentru URL state în App Router? Mergeți pe variante lightweight create manual sau folosiți librării gen nuqs?

Răspunsuri 0

Se încarcă răspunsurile…

Loghează-te pentru a răspunde

Doar membrii comunității pot lăsa comentarii.