circuit

Building a pagination component in React with TypeScript

How to create a pagination component in react with TypeScript




In this article, I'll be covering how to create a pagination component in react with TypeScript. The aim is to make it as generic as possible. It will be a presentation component, also known as “dumb component” because it only displays the html with styles (doesn't have state or logic).

As usual, my aim is to be able to have it working in less than 10 minutes. I'm going to build it with React TypeScript and using CSS Modules with Sass. I am starting to enjoying it a lot as it allows to localize the styling to your component so I don't have to worry as much for the naming in the app. For testing, I'll use jest with enzyme.

How it should work?

As a reference we'll use the pagination component of Airbnb. You an see how it works on the image below. These are the main characteristics:

  • It always shows the first and the last page.

  • It always shows at least 3 consecutive pages, usually the page you are on, the previous one at the left and the next one at the right. In the case of the first one, displays the first 3 and in the last page it displays the last 3 pages.

  • It uses 3 dots as separator between the first page and the previous page that we are on. Also between the next page that we are on and the last page.

  • The previous page indicator appears except when in the first page. The next page indicator appears except when in the last page.

Building

The best approach to start is probably the most common cases and improve from there. The extremes numbers are probably the ones that have more complex logic so maybe the best is to start with the middle ones. For example, 5 to 8. In order to make the component as generic as possible, we are going to use a callback so we can handle the transitions in the parent component.

Component Tree DiagramComponent Tree Diagram

The properties that the pagination component use are:

  • page: the page number where we are.

  • totalPages: the total amount of pages.

  • handlePagination: callback function that handles the change of pagination.

So let's get to it! First of all, I'm going to share all the styling to make it easier. Feel free to update it however you want.

// pagination.module.scss

[@import](http://twitter.com/import) 'src/styles/colours.scss';
// Colour variables used: $primary, $white

.paginationWrapper {
  padding: 2rem 0;
  display: flex;
  justify-content: center;
}

.separator {
  width: 1rem;
  margin: 0 0.25rem;
}

.pageItem {
  background: transparent;
  border: none;
  height: 2rem;
  width: 2rem;
  margin: 0 0.25rem;
  border-radius: 50%;
  font-weight: 600;
  color: $primary;

&:hover {
    text-decoration: underline;
  }

&:focus {
    outline: 0;
  }
}

.active {
  background-color: $primary;
  color: $white;
}

.sides {
  box-shadow: transparent 0px 0px 0px 1px, transparent 0px 0px 0px 4px, rgba(0, 0, 0, 0.18) 0px 2px 4px;

&:hover {
    text-decoration: none;
    box-shadow: transparent 0px 0px 0px 1px, transparent 0px 0px 0px 4px, rgba(0, 0, 0, 0.12) 0px 6px 16px;
  }
}

In order to have a working prototype, instead of consuming an API, you can use this pagination container

import React, { useState } from 'react';

import { Pagination } from './pagination';

export const PaginationContainer = () => {
  const [page, setPage] = useState(1);
  const totalPages = 15;
  const handlePages = (updatePage: number) => setPage(updatePage);

return (
    <div className="container">
      <Pagination
        page={page}
        totalPages={totalPages}
        handlePagination={handlePages}
      />
    </div>
  );
};

It's a very easy one, initializing the page at 1 with a total of 15 pages, the callback function will update the state.

For the component, the initial step is to display the page in which the user is, the pages next to it, the dots that indicate there are more, the first page and the last page and the sides for previous and next.

import React from 'react';
import classNames from 'classnames';

import styles from './pagination.module.scss';

export interface Props {
  page: number;
  totalPages: number;
  handlePagination: (page: number) => void;
}

export const PaginationComponent: React.FC<Props> = ({
  page,
  totalPages,
  handlePagination,
}) => {
  return (
    <div className={styles.pagination}>
      <div className={styles.paginationWrapper}>
        <button
          onClick={() => handlePagination(page - 1)}
          type="button"
          className={classNames([styles.pageItem, styles.sides].join(' '))}
        >
          &lt;
        </button>

        <button
          onClick={() => handlePagination(1)}
          type="button"
          className={classNames(styles.pageItem)}
        >
          {1}
        </button>

        <div className={styles.separator}>...</div>

        <button
          onClick={() => handlePagination(page - 1)}
          type="button"
          className={styles.pageItem}
        >
          {page - 1}
        </button>

        <button
          onClick={() => handlePagination(page)}
          type="button"
          className={[styles.pageItem, styles.active].join(' ')}
        >
          {page}
        </button>

        <button
          onClick={() => handlePagination(page + 1)}
          type="button"
          className={styles.pageItem}
        >
          {page + 1}
        </button>

<div className={styles.separator}>...</div>

        <button
          onClick={() => handlePagination(totalPages)}
          type="button"
          className={classNames(styles.pageItem, {
            [styles.active]: page === totalPages,
          })}
        >
          {totalPages}
        </button>

        <button
          onClick={() => handlePagination(page + 1)}
          type="button"
          className={[styles.pageItem, styles.sides].join(' ')}
        >
          &gt;
        </button>
      </div>
    </div>
  );
};

export const Pagination = PaginationComponent;

With this component, it will look extremely weird in the beginning and the end but great on the middle pages.

Page 5 good viewPage 5 good view

Page 1 looks awfulPage 1 looks awful

Last page looks badlyLast page looks badly

In order to solve the issue, we just need some logic to display based on certain cases.

import React from 'react';
import classNames from 'classnames';

import styles from './pagination.module.scss';

export interface Props {
  page: number;
  totalPages: number;
  handlePagination: (page: number) => void;
}

export const PaginationComponent: React.FC<Props> = ({
  page,
  totalPages,
  handlePagination,
}) => {
  return (
    <div className={styles.pagination}>
      <div className={styles.paginationWrapper}>
        {page !== 1 && (
          <button
            onClick={() => handlePagination(page - 1)}
            type="button"
            className={classNames([styles.pageItem, styles.sides].join(' '))}
          >
            &lt;
          </button>
        )}

        <button
          onClick={() => handlePagination(1)}
          type="button"
          className={classNames(styles.pageItem, {
            [styles.active]: page === 1,
          })}
        >
          {1}
        </button>

        {page > 3 && <div className={styles.separator}>...</div>}

        {page === totalPages && totalPages > 3 && (
          <button
            onClick={() => handlePagination(page - 2)}
            type="button"
            className={styles.pageItem}
          >
            {page - 2}
          </button>
        )}

        {page > 2 && (
          <button
            onClick={() => handlePagination(page - 1)}
            type="button"
            className={styles.pageItem}
          >
            {page - 1}
          </button>
        )}

        {page !== 1 && page !== totalPages && (
          <button
            onClick={() => handlePagination(page)}
            type="button"
            className={[styles.pageItem, styles.active].join(' ')}
          >
            {page}
          </button>
        )}

        {page < totalPages - 1 && (
          <button
            onClick={() => handlePagination(page + 1)}
            type="button"
            className={styles.pageItem}
          >
            {page + 1}
          </button>
        )}

        {page === 1 && totalPages > 3 && (
          <button
            onClick={() => handlePagination(page + 2)}
            type="button"
            className={styles.pageItem}
          >
            {page + 2}
          </button>
        )}

        {page < totalPages - 2 && <div className={styles.separator}>...</div>}

        <button
          onClick={() => handlePagination(totalPages)}
          type="button"
          className={classNames(styles.pageItem, {
            [styles.active]: page === totalPages,
          })}
        >
          {totalPages}
        </button>

        {page !== totalPages && (
          <button
            onClick={() => handlePagination(page + 1)}
            type="button"
            className={[styles.pageItem, styles.sides].join(' ')}
          >
            &gt;
          </button>
        )}
      </div>
    </div>
  );
};

export const Pagination = PaginationComponent;

Thanks to this updates, we'll be able to see the component working in all the cases

Initial valuesInitial values

Middle valuesMiddle values

End valuesEnd values

This is the component with the displaying logic. Now, it's time to test it!

Testing

In order to test it, the best option that I have thought is a combination between snapshot testing and clicking some buttons to trigger the handlePagination

As there we have added so many logic we will need at least 3 scenarios: one with the initial value 1, another with a middle value and another with the maximum number.

import React from 'react';
import { mount } from 'enzyme';

import { Pagination, Props } from '../pagination';

describe('<Pagination />', () => {
  it('renders the component from the beginning', () => {
    const mockProps: Props = {
      page: 1,
      totalPages: 5,
      handlePagination: jest.fn(),
    };
    const wrapper = mount(<Pagination {...mockProps} />);

    wrapper.find('button').at(0).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(1);

    wrapper.find('button').at(1).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(2);

    wrapper.find('button').at(2).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(3);

    wrapper.find('button').at(3).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(5);

    wrapper.find('button').at(4).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(2);

    expect(wrapper.render()).toMatchSnapshot();
  });

it('renders the component from the end', () => {
    const mockProps: Props = {
      page: 5,
      totalPages: 5,
      handlePagination: jest.fn(),
    };
    const wrapper = mount(<Pagination {...mockProps} />);

    wrapper.find('button').at(0).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(4);

    wrapper.find('button').at(1).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(1);

    wrapper.find('button').at(2).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(3);

    wrapper.find('button').at(3).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(4);

    wrapper.find('button').at(4).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(5);

expect(wrapper.render()).toMatchSnapshot();
  });

it('renders the component from the middle', () => {
    const mockProps: Props = {
      page: 3,
      totalPages: 5,
      handlePagination: jest.fn(),
    };
    const wrapper = mount(<Pagination {...mockProps} />);

    wrapper.find('button').at(0).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(2);

    wrapper.find('button').at(1).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(1);

    wrapper.find('button').at(2).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(2);

    wrapper.find('button').at(3).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(3);

    wrapper.find('button').at(4).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(4);

    wrapper.find('button').at(5).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(5);

    wrapper.find('button').at(6).simulate('click');
    expect(mockProps.handlePagination).toBeCalledWith(4);

    expect(wrapper.render()).toMatchSnapshot();
  });
});

With this, we have all the component properly tested!!

Improvements

As always, there is probably room for improvements. If you have any ideas or suggestions please comment below.




Continue Learning