Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save EduardoSP6/a3b75748c9d625764409885e0d2db4e5 to your computer and use it in GitHub Desktop.
Save EduardoSP6/a3b75748c9d625764409885e0d2db4e5 to your computer and use it in GitHub Desktop.
Implementing pagination in PHP with Spatie Query Builder + React Tanstack Table

Implementing pagination in PHP with library Spatie Query Builder + React Tanstack Table

Tested with:

Back-end implementation:

Controller - index method:

    public function index(): Response|AnonymousResourceCollection
    {
        $page = (int)request()->query("page", 1);
        $perPage = (int)request()->query("limit", 15);

        $transactions = (new ListUsersUseCase($this->userRepository))
            ->execute($page, $perPage);

        if ($transactions->isEmpty()) {
            return response()->noContent();
        }

        return UserListResource::collection($transactions);
    }

UseCase:

class ListUsersUseCase
{
    private UserRepository $userRepository;

    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    public function execute(int $page = 1, int $perPage = 15, array $columns = array('*'))
    {
        return $this->userRepository->paginate($page, $perPage, $columns);
    }
}

Repository:

class UserEloquentRepository implements UserRepository
{

    public function paginate(int $page = 1, int $perPage = 15, array $columns = array('*')): LengthAwarePaginator
    {
        return QueryBuilder::for(UserModel::class)
            ->allowedSorts(['name', 'email'])
            ->defaultSort('name')
            ->paginate($perPage, $columns, 'page', $page);
    }
 }   

Front-end implementation:

Datatable paginator to handle paginator object returned from Laravel LengthAwarePaginator:

export interface DataTablePaginator {
  current_page: number;
  from?: number;
  last_page?: number;
  total_pages?: number;
  path?: string;
  per_page: number;
  to?: number;
  total: number;
  total_items?: number;
  links: PaginatorLink[];
}

export interface PaginatorLink {
  url?: string;
  label: string;
  active: boolean;
}

Fecth in Http Gateway class:

Note: param page is the current page number, and limit is per page records.

export interface PaginatedUsers {
  users: IUser[] | [];
  paginator: DataTablePaginator | null;
}

export class UserHttpGateway implements UserGateway {
  async paginate(
    page: number,
    perPage: number,
    filters?: string
  ): Promise<PaginatedUsers> {
    const data = await api(
      `users?page=${page}&limit=${perPage}` +
        (filters ? "&" + filters : ""),
      {
        method: "GET",
      }
    );

    const users = data.data.map(
      (data: any) =>
        new User({
          uuid: data.uuid,
          name: data.name,
          email: data.email,
          createdAt: data.createdAt,
        })
    );

    const paginator: DataTablePaginator = { ...data.meta };

    return {
      users: users,
      paginator: paginator,
    };
  }
}  

Table component:

Note: The component below receives the DataTablePaginator as a property, which I declare as a state on the listing page. The most important point is the handlePageChange function that updates the internal state of the table when the user switches the page. Another detail but no less important is that the table pagination starts at 0, while in the back-end it starts at 1. Therefore, we have some adjustments in the state and in the handlePageChange function.

"use client";

import { useState } from "react";

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
  PaginationState,
  Updater,
} from "@tanstack/react-table";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

import { Button } from "@/components/ui/button";
import { DataTablePaginator } from "@/lib/data-table-paginator";

interface UserDataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  dataPaginator: DataTablePaginator;
  onPaginationChanged: (currentPage: number, perPage: number) => void;
}

export function UserDataTable<TData, TValue>({
  data,
  columns,
  dataPaginator,
  onPaginationChanged,
}: UserDataTableProps<TData, TValue>) {
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: dataPaginator.current_page - 1,
    pageSize: dataPaginator.per_page,
  });

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    state: {
      pagination,
    },
    onPaginationChange: handlePageChange,
    manualPagination: true, //we're doing manual "server-side" pagination
    rowCount: dataPaginator.total,
  });

  async function handlePageChange(updaterOrValue: Updater<any>) {
    await setPagination(updaterOrValue);

    const currentPage = table.getState().pagination.pageIndex + 1;
    const perPage = table.getState().pagination.pageSize;
    onPaginationChanged(currentPage, perPage);
  }

  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                );
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
      <div className="mt-2 w-full grid grid-cols-2 items-center justify-between space-x-2 py-3 px-3 text-xs">
        <div className="flex items-center justify-start">
          <span className="flex items-center gap-1">
            <div>Página</div>
            <strong>
              {table.getState().pagination.pageIndex + 1} de{" "}
              {table.getPageCount().toLocaleString()}
            </strong>
          </span>
          <span className="flex items-center gap-1 ml-1">
            | Ir para:
            <input
              type="number"
              min="1"
              max={table.getPageCount()}
              defaultValue={table.getState().pagination.pageIndex + 1}
              onChange={(e) => {
                const page = e.target.value ? Number(e.target.value) - 1 : 0;
                table.setPageIndex(page);
              }}
              className="border p-1 rounded w-16"
            />
          </span>
        </div>
        <div className="flex items-center justify-end">
          <Button
            variant="outline"
            size="icon"
            onClick={() => table.firstPage()}
            disabled={!table.getCanPreviousPage()}
          >
            {"<<"}
          </Button>
          <Button
            variant="outline"
            size="icon"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            {"<"}
          </Button>
          <Button
            variant="outline"
            size="icon"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            {">"}
          </Button>
          <Button
            variant="outline"
            size="icon"
            onClick={() => table.lastPage()}
            disabled={!table.getCanNextPage()}
          >
            {">>"}
          </Button>
        </div>
      </div>
      <div className="flex items-center justify-end text-xs px-3 pb-1">
        Exibindo {table.getRowModel().rows.length.toLocaleString()} de{" "}
        {dataPaginator.total.toLocaleString()} linhas
      </div>
    </div>
  );
}

Table columns:

export const columns: ColumnDef<IUser>[] = [
    {
        header: "Name",
        accessorKey: "name",
    },
    {
        header: "Email",
        accessorKey: "email",
    },
    {
        header: "Created at",
        accessorKey: "createdAt",
        cell: ({ row }) => {
        return (
         <div>
            {moment(row.getValue("createdAt")).format("DD/MM/YY HH:mm:ss")}
         </div>
        );
     },
  },
]

The list page:

Note: The function handlePaginationChange is the callback of DataTable onPageChange event. This function update the userPaginator causing useEffect hook to make a new back-end request displaying the records on the new page.

"use client";

....

export default function UserListPage() {
  const [users, setUsers] = useState<IUser[]>([]);
  const [userPaginator, setUserPaginator] =
    useState<DataTablePaginator>({
      current_page: 1,
      from: 1,
      last_page: 1,
      total_pages: 1,
      path: "",
      per_page: 10,
      to: 1,
      total: 0,
      total_items: 0,
      links: [],
    });

  const fetchUsers = async () => {
    try {
      const listUsersUseCase = new ListUsersUseCase(
        new UserHttpGateway()
      );

      const { users, paginator } =
        await listUsersUseCase.execute(
          userPaginator.current_page,
          userPaginator.per_page
        );

      if (paginator) {
        setUsers(users);
        setUserPaginator(paginator);
      } else {
        setUsers([]);
      }
    } catch (e: any) {
      toast({
        variant: "destructive",
        title: "Error",
        description: e.message,
      });
    }
  };

  useEffect(() => {
    fetchUsers();
  }, [userPaginator.current_page, userPaginator.per_page]);

  const handlePaginationChange = (currentPage: number, perPage: number) => {
    setUserPaginator((prevState) => ({
      ...prevState,
      current_page: currentPage,
      per_page: perPage,
    }));
  };

  return (
    <div>
      <h1>Users list</h1>
      <div className="container mx-auto py-10">
        <Suspense fallback={<Loading />}>
          <UserDataTable
            columns={columns}
            data={users}
            dataPaginator={userPaginator}
            onPaginationChanged={handlePaginationChange}
          />
        </Suspense>
      </div>
    </div>
  );
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment