Courses

React Laravel 12 Starter Kit: CRUD Project

Table Pagination with React "Helper"

Summary of this lesson:
- Implementing Laravel pagination
- Creating TypeScript interface for paginated response data
- Building reusable TablePagination component with Shadcn
- Adding advanced pagination with numbered page navigation

In this lesson, we will add pagination to our table. It won't be an easy one-minute process.


Modifying Controller for Pagination

app/Http/Controllers/TaskController.php

// ...
 
public function index()
{
return Inertia::render('Tasks/Index', [
'tasks' => Task::all()
'tasks' => Task::paginate(5)
]);
}
 
// ...

Now, our Task List page will stop working because of a TypeScript structure mismatch. We return paginate() from Controller, which is no longer the array of the Task type. Let's fix this.


Creating Paginated Response Type

We return tasks from the Controller not as a list but as a paginated object.

So, we need to create a specific new type for it, corresponding to the structure returned by Laravel pagination.

resources/js/types/index.d.ts

// ...
 
export interface PaginatedResponse<T = Task | null> {
current_page: number;
data: T[];
first_page_url: string;
from: number;
last_page: number;
last_page_url: string;
links: {
url: string | null;
label: string;
active: boolean;
}[];
next_page_url: string | null;
path: string;
per_page: number;
prev_page_url: string | null;
to: number;
total: number;
}

Accepting Paginated Response in React

In three places, we need to change the type of data we get.

  • We import our new PaginatedResponse type
  • We use it in the export function instead of the old list type
  • We change tasks.map() to tasks.data.map() because Laravel returns the data in tasks.data now

resources/js/pages/Tasks/Index.tsx

import { type BreadcrumbItem, type Task } from '@/types';
import { type BreadcrumbItem, type PaginatedResponse, type Task } from '@/types';
 
// ...
 
export default function Index({ tasks }: { tasks: Task[] }) {
export default function Index({ tasks }: { tasks: PaginatedResponse<Task> }) {
// ...
return (
// ...
{tasks.map((task: Task) => (
{tasks.data.map((task: Task) => (
<TableRow key={task.id}>
// ...

Ok, now our page should load successfully again.

But wait... we still don't have pagination?


Pagination Component from Shadcn

Shadcn has a component of Pagination.

So, we install it with a regular npx command.

npx shadcn@latest add pagination

We could use it right away, but I suggest considering the future when we will use the same pagination on other pages. So, we will build our own reusable pagination component suitable for our design.


Creating a "Simple" Pagination Component

Shadcn Pagination gives us the components like <Pagination> and others. Here's an example of Usage from the docs:

Let's use those <Pagination>, <PaginationContent>, <PaginationItem> and other components in our own new React component we'll call <TablePagination>, custom to our own design.

For now, we will add only "Next" and "Previous" links.

resources/js/components/table-pagination.tsx

import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from '@/components/ui/pagination';
import { type PaginatedResponse } from '@/types';
 
export function TablePagination({ resource }: { resource: PaginatedResponse }) {
if (resource.last_page === 1) {
return (
<div className={'mt-4 text-center text-gray-500'}>
No more items to show.
</div>
);
}
 
return (
<Pagination className='mt-4'>
<PaginationContent>
<PaginationItem>
{resource.prev_page_url
? <PaginationPrevious href={resource.prev_page_url} />
: null
}
</PaginationItem>
<PaginationItem>
{resource.next_page_url
? <PaginationNext href={resource.next_page_url} />
: null
}
</PaginationItem>
</PaginationContent>
</Pagination>
);
}

Then, in our main component:

resources/js/pages/Tasks/Index.tsx

// Import first
import { TablePagination } from '@/components/table-pagination';
 
// ...
 
// Then use below
<TablePagination resource={tasks} />
</div>
</AppLayout>

Here's the visual result:

If we click "Next", the URL changes, and we see the "Previous" button instead.

Now, if we want to build pagination with numbers, it's more complicated.


Pagination with Numbers

Shadcn is a CSS component library. It's for the presentation layer. It doesn't contain the logic for calculating and showing the page numbers. We need to handle this logic ourselves in custom JavaScript.

It's a pretty complex logic. Luckily, I've found a ready-made Gist called Shadcn UI implementation of DataTable and Pagination components working together. So, from that, we've created our own React component "helper". It doesn't return the visual HTML, so I put it in a resources/js/lib subfolder, not resources/js/components.

It will accept:

  • Current page to mark the active page
  • Total pages to determine the logic
  • Path to generate the links for (e.g. /tasks)
  • Page query to append to the path (e.g., ?page=)

resources/js/lib/generate-pagination-links.tsx

import { PaginationEllipsis, PaginationItem, PaginationLink } from '@/components/ui/pagination';
import { JSX } from 'react';
 
type PaginationLink = {
url: string;
label: string;
}
 
export const generatePaginationLinks = (currentPage: number, totalPages: number, path: string, links: PaginationLink[], pageQuery: string = '?page=') => {
const pages: JSX.Element[] = [];
console.log(links)
if (totalPages <= 6) {
for (let i = 1; i <= totalPages; i++) {
pages.push(
<PaginationItem key={i}>
<PaginationLink href={links[i].url} isActive={i === currentPage}>
{i}
</PaginationLink>
</PaginationItem>,
);
}
} else {
for (let i = 1; i <= 2; i++) {
pages.push(
<PaginationItem key={i}>
<PaginationLink href={links[i].url} isActive={i === currentPage}>
{i}
</PaginationLink>
</PaginationItem>,
);
}
if (2 < currentPage && currentPage < totalPages - 1) {
pages.push(<PaginationEllipsis />);
pages.push(
<PaginationItem key={currentPage}>
<PaginationLink href="" isActive={true}>
{currentPage}
</PaginationLink>
</PaginationItem>,
);
}
pages.push(<PaginationEllipsis />);
for (let i = totalPages - 1; i <= totalPages; i++) {
pages.push(
<PaginationItem key={i}>
<PaginationLink href={links[i].url} isActive={i === currentPage}>
{i}
</PaginationLink>
</PaginationItem>,
);
}
}
return pages;
};

As you can see, it returns pages. Now, how do we use those pages in the table?


Using Pagination Lib Helper in Component

Now we can use the pagination component in our Index page:

resources/js/components/table-pagination.tsx

import { generatePaginationLinks } from '@/lib/generate-pagination-links';
 
// ...
 
return (
<Pagination className='mt-4'>
<PaginationContent>
<PaginationItem>
{resource.prev_page_url
? <PaginationPrevious href={resource.prev_page_url} />
: null
}
</PaginationItem>
 
{generatePaginationLinks(resource.current_page, resource.last_page, resource.path, resource.links)}// [tl! ++]
 
<PaginationItem>
{resource.next_page_url
? <PaginationNext href={resource.next_page_url} />
: null
}
</PaginationItem>
</PaginationContent>
</Pagination>
);

Now we have a proper numbered pagination:

Also, if we change the pagination to 10 records per page, we show the text on the bottom that there are no more records:


Here's the GitHub commit for the two lessons: this one about Pagination and the next one about Date Picker. (sorry, forgot to commit separately)

Previous: Edit Form and Breadcrumbs
avatar

The shadcn pagination component uses a regular <a> tag for the links, so you'll notice that the pagination links cause a full page refresh. This can be remedied by updating the shadcn component to use the Inertia <Link> component.

The great thing about shadcn and the way that the Laravel Starter Kits are implemented is that you can modify the components in this way, so no messy extending. Just be careful not to overwrite the changes later!

👍 2
avatar

Thanks for the valuable comment, Joe, great notice!

avatar

That's a great point Joe however I just tried to naively update resources/js/components/ui/pagination.tsx to use "Link" instead of "a" and got myself into all sorts of trouble. Anyone got some hints or a Gist?

avatar

Yeah, it's pretty easy to break. I ended up enlisting the help of AI to do it. Here's what we came up with...

First import the inertia <Link> at the top:

import { Link } from "@inertiajs/react"

Then redefine the PaginationLinkProps type slightly:

type PaginationLinkProps = {
  isActive?: boolean
  href: string
  size?: "default" | "sm" | "lg" | "icon"
  className?: string
  children?: React.ReactNode
}

Finally replace the PaginationLink function with this:

function PaginationLink({
  className,
  isActive,
  size = "icon",
  href,
  children,
  ...props
}: PaginationLinkProps) {
  return (
    <Link
      href={href}
      aria-current={isActive ? "page" : undefined}
      className={cn(
        buttonVariants({
          variant: isActive ? "outline" : "ghost",
          size,
        }),
        className
      )}
      {...props}
    >
      {children}
    </Link>
  )
}
avatar
You can use Markdown
avatar

Just an observation, after the custom table-pagination.tsx component is created, the step where the component is used is missing. This can be confusing to readers who are just following along. The following needs to be added to the Tasks/Index.tsx for the pagination to show;

//Import first
import { TablePagination } from '@/components/table-pagination';

// Then use below
        <TablePagination resource={tasks} />
     </div>
</AppLayout>
👍 3
avatar

Wow, how could I miss this... fixed now! Thanks Steve for being a proofreader! Need to spend more time on double-checking everything in the future. Perhaps too much in a hurry to release everything related to Laravel 12, when people are asking so many question.

avatar

No worries my friend, happy to help! Hope to see you at Laravel Live London :)

avatar
You can use Markdown
avatar
Each child in a list should have a unique "key" prop.

Check the render method of `TablePagination`. See https://react.dev/link/warning-keys for more information. Error Component Stack
    at TablePagination (table-pagination.tsx:5:35)
    at div (<anonymous>)
    at main (<anonymous>)
    at SidebarInset (sidebar.tsx:302:25)
    at AppContent (app-content.tsx:8:30)
    at div (<anonymous>)
    at TooltipProvider (tooltip.tsx:7:3)
    at SidebarProvider (sidebar.tsx:55:3)
    at AppShell (app-shell.tsx:9:28)
    at AppSidebarLayout (app-sidebar-layout.tsx:9:44)
    at default (app-layout.tsx:10:19)
    at Index (Index.tsx:17:5)

I get error in console log. And I fixed it in resources/js/lib/generate-pagination-links.tsx by adding 2 keys to PaginationEllipsis key="ellipsis-before" and key="ellipsis-after"

...
if (2 < currentPage && currentPage < totalPages - 1) {
            pages.push(<PaginationEllipsis key="ellipsis-before"/>);
            pages.push(
                <PaginationItem key={currentPage}>
                    <PaginationLink href="" isActive={true}>
                        {currentPage}
                    </PaginationLink>
                </PaginationItem>,
            );
        }
        pages.push(<PaginationEllipsis key="ellipsis-after"/>);
        for (let i = totalPages - 1; i <= totalPages; i++) {
            pages.push(
                <PaginationItem key={i}>
                    <PaginationLink href={path + pageQuery + i} isActive={i === currentPage}>
                        {i}
                    </PaginationLink>
                </PaginationItem>,
            );
        }
avatar
You can use Markdown
avatar
You can use Markdown