# Data grid with server side pagination

> Data grid with server side pagination, sorting, search and status filter

A shadcn/ui block from the [@reui](https://reui.io) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/reui/c-data-grid-34).

- Page, with a live preview: https://shadcn.exchange/r/reui/c-data-grid-34
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/reui/c-data-grid-34-light-df3d353d50522b7a.png
- Registry item: https://reui.io/r/new-york-v4/c-data-grid-34.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://reui.io/r/new-york-v4/c-data-grid-34.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tanstack/react-table`
- Registry items: `avatar`, `@reui/badge`, `button`, `card`, `@reui/data-grid`, `@reui/data-grid-column-header`, `@reui/data-grid-pagination`, `@reui/data-grid-scroll-area`, `@reui/data-grid-table`, `input`, `select`, `skeleton`

## Files

### `c-data-grid-34.tsx`

```tsx
"use client"

import { useEffect, useMemo, useRef, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  DataGrid,
  dataGridFeatures,
  type DataGridFeatures,
} from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridScrollArea } from "@/components/reui/data-grid/data-grid-scroll-area"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
  ColumnDef,
  PaginationState,
  SortingState,
  useTable,
} from "@tanstack/react-table"

import {
  Avatar,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"

interface IData {
  id: string
  name: string
  avatar: string
  email: string
  company: string
  status: "Active" | "Inactive" | "Pending"
  balance: number
}

type StatusFilter = "all" | IData["status"]

const avatars = [
  "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
  "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
  "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
  "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
  "https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
  "https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
]

const names = [
  "Alex Johnson",
  "Sarah Chen",
  "Michael Rodriguez",
  "Emma Wilson",
  "David Kim",
  "Aron Thompson",
  "James Brown",
  "Maria Garcia",
  "Nick Johnson",
  "Liam Thompson",
]

const companies = [
  "Apple",
  "OpenAI",
  "Meta",
  "Tesla",
  "SAP",
  "Keenthemes",
  "BBVA",
  "Sony",
  "LVMH",
  "ENI",
]

const statuses: IData["status"][] = ["Active", "Inactive", "Pending"]

// Deterministic, so sorting and paging are reproducible across fetches. A
// non-round total makes the "1 - 5 of 487" info text read like a real API.
const TOTAL_SERVER_RECORDS = 487

const serverRecords: IData[] = Array.from(
  { length: TOTAL_SERVER_RECORDS },
  (_, index) => {
    const name = names[index % names.length]
    return {
      id: String(index + 1),
      name,
      avatar: avatars[index % avatars.length],
      email: `${name.toLowerCase().replace(" ", ".")}${index + 1}@company.com`,
      company: companies[(index * 3) % companies.length],
      status: statuses[index % statuses.length],
      balance: Math.round((1000 + ((index * 137.17) % 9000)) * 100) / 100,
    }
  }
)

/**
 * The simulated server. Everything inside this function is what a real
 * backend does with the query string of a paged endpoint: filter, sort, count,
 * slice. Replace the whole body with a fetch to your own API and keep the
 * return shape - one page of rows plus the total AFTER filtering.
 */
async function fetchServerPage(params: {
  pageIndex: number
  pageSize: number
  sorting: SortingState
  search: string
  status: StatusFilter
}): Promise<{ rows: IData[]; total: number }> {
  // Latency, so the built-in skeleton state is actually visible in the demo.
  await new Promise((resolve) => setTimeout(resolve, 500))

  const search = params.search.toLowerCase()
  let rows = serverRecords
  if (params.status !== "all") {
    rows = rows.filter((record) => record.status === params.status)
  }
  if (search) {
    rows = rows.filter((record) =>
      [record.name, record.email, record.company].some((value) =>
        value.toLowerCase().includes(search)
      )
    )
  }

  const sort = params.sorting[0]
  if (sort) {
    const direction = sort.desc ? -1 : 1
    rows = [...rows].sort((a, b) => {
      const left = a[sort.id as keyof IData]
      const right = b[sort.id as keyof IData]
      if (typeof left === "number" && typeof right === "number") {
        return (left - right) * direction
      }
      return String(left).localeCompare(String(right)) * direction
    })
  }

  const start = params.pageIndex * params.pageSize
  return {
    rows: rows.slice(start, start + params.pageSize),
    total: rows.length,
  }
}

export function Pattern() {
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 5,
  })
  const [sorting, setSorting] = useState<SortingState>([])
  const [searchInput, setSearchInput] = useState("")
  const [search, setSearch] = useState("")
  const [status, setStatus] = useState<StatusFilter>("all")
  const [data, setData] = useState<IData[]>([])
  const [total, setTotal] = useState(0)
  const [isLoading, setIsLoading] = useState(true)

  const hasActiveFilters = searchInput.trim() !== "" || status !== "all"

  const resetToFirstPage = () =>
    setPagination((current) =>
      current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
    )

  // Debounce typing into one server query, and return to the first page: the
  // old page index is meaningless against a different filtered set.
  useEffect(() => {
    const timeoutId = window.setTimeout(() => {
      setSearch(searchInput.trim())
      setPagination((current) =>
        current.pageIndex === 0 ? current : { ...current, pageIndex: 0 }
      )
    }, 350)
    return () => window.clearTimeout(timeoutId)
  }, [searchInput])

  // One fetch per settled query state. The request id guards against a slow
  // response landing after a newer one and overwriting fresher rows.
  const requestIdRef = useRef(0)
  useEffect(() => {
    const requestId = ++requestIdRef.current
    setIsLoading(true)
    fetchServerPage({
      pageIndex: pagination.pageIndex,
      pageSize: pagination.pageSize,
      sorting,
      search,
      status,
    }).then((response) => {
      if (requestId !== requestIdRef.current) return
      setData(response.rows)
      setTotal(response.total)
      setIsLoading(false)
    })
  }, [pagination.pageIndex, pagination.pageSize, sorting, search, status])

  const columns = useMemo<ColumnDef<DataGridFeatures, IData>[]>(
    () => [
      {
        accessorKey: "name",
        id: "name",
        header: ({ column }) => (
          <DataGridColumnHeader title="User" column={column} />
        ),
        cell: ({ row }) => (
          <div className="flex items-center gap-3">
            <Avatar className="size-7">
              <AvatarImage src={row.original.avatar} alt={row.original.name} />
              <AvatarFallback>
                {row.original.name
                  .split(" ")
                  .map((namePart) => namePart[0])
                  .join("")}
              </AvatarFallback>
            </Avatar>
            <div>
              <div className="text-foreground font-medium">
                {row.original.name}
              </div>
              <div className="text-muted-foreground text-xs">
                {row.original.email}
              </div>
            </div>
          </div>
        ),
        minSize: 220,
        meta: {
          autoSize: true,
          skeleton: (
            <div className="flex items-center gap-3">
              <Skeleton className="size-7 rounded-full" />
              <div className="space-y-1.5">
                <Skeleton className="h-3.5 w-24" />
                <Skeleton className="h-3 w-36" />
              </div>
            </div>
          ),
        },
        enableSorting: true,
      },
      {
        accessorKey: "company",
        id: "company",
        header: ({ column }) => (
          <DataGridColumnHeader title="Company" column={column} />
        ),
        cell: ({ row }) => (
          <span className="text-foreground">{row.original.company}</span>
        ),
        size: 130,
        meta: {
          skeleton: <Skeleton className="h-4 w-20" />,
        },
        enableSorting: true,
      },
      {
        accessorKey: "status",
        id: "status",
        header: ({ column }) => (
          <DataGridColumnHeader title="Status" column={column} />
        ),
        cell: ({ row }) => {
          const rowStatus = row.original.status

          if (rowStatus === "Active") {
            return <Badge variant="success-outline">Active</Badge>
          }

          if (rowStatus === "Inactive") {
            return <Badge variant="info-outline">Inactive</Badge>
          }

          return <Badge variant="warning-outline">Pending</Badge>
        },
        size: 120,
        meta: {
          skeleton: <Skeleton className="h-5 w-16" />,
        },
        enableSorting: true,
      },
      {
        accessorKey: "balance",
        id: "balance",
        header: ({ column }) => (
          <DataGridColumnHeader title="Balance" column={column} />
        ),
        cell: ({ row }) => (
          <span className="tabular-nums">
            $
            {row.original.balance.toLocaleString("en-US", {
              minimumFractionDigits: 2,
            })}
          </span>
        ),
        size: 140,
        meta: {
          skeleton: <Skeleton className="h-4 w-24" />,
        },
        enableSorting: true,
      },
    ],
    []
  )

  const table = useTable({
    features: dataGridFeatures,
    columns,
    data,
    // Server-side mode: `data` is exactly one page, so paging and sorting are
    // the server's job instead of the row models'.
    manualPagination: true,
    manualSorting: true,
    // The server-side total. REQUIRED alongside recordCount below: without it
    // getPageCount() is derived from the one loaded page and the pagination
    // buttons collapse to a single page, while the info text still claims the
    // full count. recordCount only drives the "1 - 5 of N" text.
    rowCount: total,
    getRowId: (row: IData) => row.id,
    state: {
      pagination,
      sorting,
    },
    onPaginationChange: setPagination,
    onSortingChange: setSorting,
  })

  return (
    <DataGrid
      table={table}
      recordCount={total}
      isLoading={isLoading}
      tableLayout={{ columnsResizable: true }}
    >
      <Card className="w-full gap-3 py-3.5">
        <CardHeader className="items-center px-3.5">
          <CardTitle className="flex items-center gap-2">
            Users
            <Badge variant="secondary" size="sm" className="tabular-nums">
              {total}
            </Badge>
          </CardTitle>
          <CardAction className="flex flex-wrap items-center justify-end gap-2">
            <Input
              value={searchInput}
              onChange={(event) => setSearchInput(event.target.value)}
              aria-label="Search users"
              placeholder="Search name, email, company..."
              className="w-56"
            />
            <Select
              value={status}
              /* The primitive types the callback value as nullable; a null
                 (nothing selected) means the unfiltered view. */
              onValueChange={(next) => {
                setStatus((next ?? "all") as StatusFilter)
                resetToFirstPage()
              }}
            >
              <SelectTrigger className="w-32" aria-label="Filter by status">
                {/* Explicit label: the primitive can only resolve a value to
                    its item label after the popup has mounted the items, so a
                    fresh render would show the raw "all". */}
                <SelectValue placeholder="Status">
                  {status === "all" ? "All statuses" : status}
                </SelectValue>
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="all">All statuses</SelectItem>
                <SelectItem value="Active">Active</SelectItem>
                <SelectItem value="Inactive">Inactive</SelectItem>
                <SelectItem value="Pending">Pending</SelectItem>
              </SelectContent>
            </Select>
            {/* Present only while a filter is active, so the resting toolbar
                stays quiet. Search is cleared through both halves at once:
                waiting out the debounce would leave stale rows for 350ms. */}
            {hasActiveFilters && (
              <Button
                variant="ghost"
                size="sm"
                onClick={() => {
                  setSearchInput("")
                  setSearch("")
                  setStatus("all")
                  resetToFirstPage()
                }}
              >
                <IconPlaceholder
                  lucide="XIcon"
                  tabler="IconX"
                  hugeicons="Cancel01Icon"
                  phosphor="XIcon"
                  remixicon="RiCloseLine"
                  className="size-4"
                />
                Clear
              </Button>
            )}
          </CardAction>
        </CardHeader>
        <CardContent className="border-t p-0">
          <DataGridScrollArea>
            <DataGridTable />
          </DataGridScrollArea>
        </CardContent>
        <CardFooter className="border-none bg-transparent! px-3.5 py-0">
          <DataGridPagination />
        </CardFooter>
      </Card>
    </DataGrid>
  )
}
```
