LET'S BUILD: A React Pagination Component!
Hello Codementor! Today, I'm going to walk you through a simple Pagination component that you can build and implement with ease, call your own, showcase it, smother your loved ones with it, the opportunities are endless.
What is Pagination?
For those who might not know, it's a means of displaying only part of a data payload on a page, and giving the user the ability to go from one page to another of search results so they aren't all rendered at the same time. It makes for a better user experience and complies with best practices.
Setup
I will spin up a React app by typing in my terminal:
npx create-react-app pagination --template typescript
cd pagination
code .
The last command will open up my Visual Studio Code in the current directory. You might have to configure this on your side to setup VSCode, or your favourite editor, with the code
command.
Now time to install all nice dependencies that I would never work without!
I can't lavish enough praise for Styled Components so let's use that. I will also install faker
to make some mock data for this example, as well as react-select
for a dropdown component in my Pagination and react-icons
for some icons:
npm i styled-components @types/styled-components @faker-js/faker react-select react-icons
I like to remove all .css files except for index.css, and in index.css I like to put a css reset:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
Building The Components
You can have frontend or backend pagination. For this example, I am going to mock a backend call with a data object.
I create a file types.ts
to store all my funky Typescript things:
interface User {
id: number;
name: string;
email: string;
}
interface PaginationProps {
limit: number;
offset: number;
}
interface Pagination extends PaginationProps {
total: number;
}
interface PaginationData<T extends object> {
pagination: Pagination;
data: T[];
}
export type { User, Pagination, PaginationProps, PaginationData };
Then I create a file getTableData.ts
:
import { faker } from "@faker-js/faker";
import { PaginationData, PaginationProps } from "./types";
const getTableData = <T extends object>({
limit,
offset,
}: PaginationProps): PaginationData<T> => {
const data = Array(1000)
.fill("")
.map((_, id) => ({
id,
name: faker.name.findName(),
email: faker.internet.email(),
}))
.slice(offset, limit + offset) as T[];
return {
pagination: { limit, offset, total: 1000 },
data,
};
};
export default getTableData;
What I am doing here is accepting limit
and offset
variables, telling me how to slice the data to return a subset of it.
I create a file styles.ts
and build some component styling:
import styled from "styled-components";
const Container = styled.div`
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
`;
const TableContainer = styled.div`
width: 600px;
height: 400px;
overflow: auto;
`;
const Table = styled.table`
width: 500px;
border-collapse: collapse;
position: relative;
& th {
text-align: left;
background: #282560;
font-weight: bold;
color: white;
border: 1px solid white;
position: sticky;
}
& th,
& td {
padding: 0.3rem;
font-size: 0.7rem;
}
& tbody tr:nth-child(even) {
& td {
background: #edeef6;
}
}
`;
export { Container, Table, TableContainer };
And then code out my App.tsx
:
import React, { useEffect, useState } from "react";
import { Container, Table, TableContainer } from "./styles";
import { PaginationData, User } from "./types";
function App() {
const [data, setData] = useState<PaginationData<User>>();
const [limit, setLimit] = useState(10);
const [offset, setOffset] = useState(0);
useEffect(() => {
const getData = async () => {
const tableData = (await import("./getTableData")).default<User>({
limit,
offset,
});
setData(tableData);
};
getData();
}, [limit, offset]);
return (
<Container>
<TableContainer>
<Table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{data?.data.map((user) => {
return (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
);
})}
</tbody>
</Table>
</TableContainer>
</Container>
);
}
export default App;
I have created two state variables, one for limit
and one for offset
.
I have created a useEffect
that asynchronously fetches the data whenever the variables limit
or offset
change.
If you have done all things correctly, your folder structure will look like this:
and your app will look like this:
Doesn't look so bad. And you can see that even though there are 1000 items in the array, only 10 are loading up. That's cool. Now how do I get to see the other items? ie, How do I change the State of limit
and offset
?
Onto Pagination!
I'll create a components
folder, and then in there a file index.ts
as well as a Pagination
folder, with two files: index.tsx
and styles.ts
Code for styles.ts
:
import {
FaCaretLeft,
FaCaretRight,
FaChevronLeft,
FaChevronRight,
} from "react-icons/fa";
import styled from "styled-components";
const Container = styled.div`
width: 600px;
display: grid;
grid-template-columns: 1fr auto;
font-size: 0.65rem;
padding: 0.2rem;
`;
const factory = (Component: any = FaChevronLeft) => styled(Component)`
cursor: pointer;
`;
const Left = factory(FaChevronLeft);
const AllLeft = factory(FaCaretLeft);
const Right = factory(FaChevronRight);
const AllRight = factory(FaCaretRight);
const PageContainer = styled.div`
display: flex;
align-items: center;
`;
const Page = factory(
styled.div<{ isActive?: boolean }>`
padding: 0.2rem;
font-weight: ${({ isActive }) => isActive && "bold"};
`
);
const PageInfo = styled.div`
display: grid;
grid-template-columns: auto auto 1fr;
grid-gap: 0.4rem;
align-items: center;
`;
export {
Container,
Left,
AllLeft,
PageContainer,
Page,
AllRight,
Right,
PageInfo,
};
And for index.tsx
:
import React, { Fragment } from "react";
import Select from "react-select";
import {
AllLeft,
AllRight,
Container,
Left,
Page,
PageContainer,
PageInfo,
Right,
} from "./styles";
interface PProps {
limit: number;
offset: number;
total?: number;
setLimit: React.Dispatch<React.SetStateAction<number>>;
setOffset: React.Dispatch<React.SetStateAction<number>>;
}
const PaginationComponent: React.FC<PProps> = ({
limit,
offset,
total,
setLimit,
setOffset,
}) => {
if (typeof total !== "number") {
return <Container>Loading...</Container>;
}
const limitOptions = [10, 20, 30, 40, 50].map((value) => ({
value,
label: `${value} per page`,
}));
const from = Math.min(offset + 1, total);
const to = Math.min(offset + limit, total);
const pageCount = Math.ceil(total / limit);
const currentPage = offset / limit + 1;
const highestPossibleOffset = limit * (pageCount - 1);
const pageArray = [-2, -1, 0, 1, 2]
.map((v) => currentPage + v)
.filter((page) => page > 0 && page <= pageCount);
return (
<Container>
<PageInfo>
Showing {from} to {to} of {total} items
<Select
options={limitOptions}
value={limitOptions.find((v) => v.value === limit)}
onChange={(v) => {
setLimit(v?.value || 10);
setOffset(0);
}}
/>
</PageInfo>
{total > 0 && (
<PageContainer>
<Left onClick={() => setOffset(0)} />
<AllLeft
onClick={() => setOffset((prev) => Math.max(prev - limit, 0))}
/>
{!pageArray.includes(1) && (
<Fragment>
<Page
isActive={currentPage === 1}
onClick={() => {
setOffset(0);
}}
>
1
</Page>
<div>...</div>
</Fragment>
)}
{pageArray.map((page) => {
return (
<Page
isActive={page === currentPage}
onClick={() => {
setOffset(limit * (page - 1));
}}
>
{page}
</Page>
);
})}
{!pageArray.includes(pageCount) && (
<Fragment>
<div>...</div>
<Page
isActive={currentPage === pageCount}
onClick={() => {
setOffset(highestPossibleOffset);
}}
>
{pageCount}
</Page>
</Fragment>
)}
<AllRight
onClick={() =>
setOffset((prev) => Math.min(prev + limit, highestPossibleOffset))
}
/>
<Right onClick={() => setOffset(highestPossibleOffset)} />
</PageContainer>
)}
</Container>
);
};
export default PaginationComponent;
in the index.ts
of your components folder, add:
export { default as PaginationComponent } from "./Pagination";
Let's implement this into our App.tsx
now. Add the import:
import { PaginationComponent } from "./components";
And then add <PaginationComponent/>
before the <TableContainer>
line.
Your folder structure should look like this:
And your app:
Great! Enough copypasting and let's explain what we did.
Deep Dive
Let's dissect the Pagination Component:
if (typeof total !== "number") {
return <Container>Loading...</Container>;
}
I am assuming here that the existence of a total
value means I have data from whatever API I seek. Therefore I only want to render the component once the data has been loaded.
const limitOptions = [10, 20, 30, 40, 50].map((value) => ({
value,
label: `${value} per page`,
}));
I am setting options for my limit value, which I'll pass through to a Select component later.
const from = Math.min(offset + 1, total);
const to = Math.min(offset + limit, total);
const pageCount = Math.ceil(total / limit);
const currentPage = offset / limit + 1;
const highestPossibleOffset = limit * (pageCount - 1);
Here I am setting variables that will give me the information for the current pagination state. The mathematics is simple but effective
const pageArray = [-2, -1, 0, 1, 2]
.map((v) => currentPage + v)
.filter((page) => page > 0 && page <= pageCount);
This page array ensures that there will always only be at most 5 page options to choose from, relative to the current page, and that the page options won't be below zero or over the total number of pages.
<Select
options={limitOptions}
value={limitOptions.find((v) => v.value === limit)}
onChange={(v) => {
setLimit(v?.value || 10);
setOffset(0);
}}
/>
When the user selects a new limit, I want to set the offset to zero again or else the user will be jumped to a different page unexpectedly.
I set up the icons from react-icons
and have them perform onClick functions that take the user one page up or down, or to the beginning or end respectively.
{!pageArray.includes(1) && (
<Fragment>
<Page
isActive={currentPage === 1}
onClick={() => {
setOffset(0);
}}
>
1
</Page>
<div>...</div>
</Fragment>
)}
Here, I build 3 separate parts. The first as shown above is determining whether the pagination is by the first page. If not, I still want to show the option to go to the first page, and then an ellipse to tell the user I am far away from it.
{pageArray.map((page) => {
return (
<Page
isActive={page === currentPage}
onClick={() => {
setOffset(limit * (page - 1));
}}
>
{page}
</Page>
);
})}
Next, for each page in my array, I want to set the offset relative to that page number.
{!pageArray.includes(pageCount) && (
<Fragment>
<div>...</div>
<Page
isActive={currentPage === pageCount}
onClick={() => {
setOffset(highestPossibleOffset);
}}
>
{pageCount}
</Page>
</Fragment>
)}
Essentially the inverse of the first part, where I want an indication of distance from the last page.
The behaviour is as follows:
at the start
somewhere in the middle
at the end. Notice the ellipses indicators.
Reusability
So now we've built the barebones structure for our Pagination component, however I'd expect an app worth its salt to have more than 1 table of data, at different routes. To set a local limit and offset state seems quite cumbersome each time.
Custom Hooks to the Rescue!
I have already shown how to create a custom hook in My Other Article. Here, we can build out a very dynamic hook that will expose only what you need. Add this to the index.ts
in the Pagination folder. Import the missing useState
and useEffect
variables above as well:
const usePagination = ({ total, callback }: HookProps) => {
const [limit, setLimit] = useState(10);
const [offset, setOffset] = useState(0);
useEffect(() => {
callback({ limit, offset });
}, [limit, offset, callback]);
const paginationComponent = (
<PaginationComponent
total={total}
limit={limit}
setLimit={setLimit}
offset={offset}
setOffset={setOffset}
/>
);
return { paginationComponent, limit, offset, setLimit, setOffset };
};
export default usePagination;
Change the index.ts
in the components
folder:
export { default as usePagination } from "./Pagination";
Now let's implement it in our App.tsx
:
function App() {
const [data, setData] = useState<PaginationData<User>>();
const callback = useCallback(
({ limit, offset }: { limit: number; offset: number }) => {
const getData = async () => {
const tableData = (await import("./getTableData")).default<User>({
limit,
offset,
});
setData(tableData);
};
getData();
},
[]
);
const { paginationComponent } = usePagination({
total: data?.pagination.total,
callback,
});
return (
<Container>
{paginationComponent}
<TableContainer>
...
I am wrapping my callback function in a useCallback
or else it will cause the useEffect
in the custom hook to rerender infinitely.
Now my code is cleaner! The pagination functionality is restricted to the component and I won't need any further local states.
Wrapping Up
In this post, we coded out a neat pagination component together, making it modular and reusable, and applied it to a mock api of data we could fetch from the backend. I hope this has been informative!
Happy Coding
~ Sean
static website forms
Thank you for this very comprehensive and comprehensible post. It teached me a lot …
You’re welcome! I’m glad to have made a difference!