Codementor Events

LET'S BUILD: A React Pagination Component!

Published May 30, 2022Last updated Nov 25, 2022
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

Screenshot 2022-05-30 at 12.09.24.png

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:
Screenshot 2022-05-30 at 13.04.13.png
and your app will look like this:
Screenshot 2022-05-30 at 13.05.01.png
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:
Screenshot 2022-05-30 at 15.17.45.png
And your app:Screenshot 2022-05-30 at 15.19.03.png
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:
Screenshot 2022-05-30 at 15.41.01.png
at the start
Screenshot 2022-05-30 at 15.41.51.png
somewhere in the middle
Screenshot 2022-05-30 at 15.42.21.png
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

Discover and read more posts from Sean Hurwitz
get started
post comments3Replies
Geoffrey Callaghan
2 years ago
Thomas Theiner
2 years ago

Thank you for this very comprehensive and comprehensible post. It teached me a lot …

Sean Hurwitz
2 years ago

You’re welcome! I’m glad to have made a difference!