Friday, April 19

Build Custom Pagination with React

 
Often times, we get involved in building web apps in which we are required to fetch large sets of data records from a remote server, API or some database sitting somewhere. If you are building a payment system for example, it could be fetching thousands of transactions. If it is a social media app, it could be fetching tonnes of user comments, profiles or activities. Whichever it is, there are a couple of methods for handling the data such that it doesn’t become overwhelming to the end-user interacting with the app.

One of the popular methods for handling large datasets on the view is by using the infinite scrolling technique – where more data is loaded in chunks as the user continues scrolling very close to the end of the page. This is the technique used in displaying search results in Google Images. It is also used in so many social media platforms like Facebook – displaying posts on timeline, Twitter – showing recent tweets, etc.

Another known method for handling large datasets is using pagination. Pagination works effectively when you already know the size of the dataset(the total number of records in the dataset) upfront. Secondly, you only load the required chunk of data from the total dataset based on the end-users interaction with the pagination control. This is the technique used in displaying search results in Google Search.

In this tutorial, we will see how to build a custom pagination component with React for paginating large datasets. In order to keep things as simple as possible, we will build a paginated view of the countries in the world – of which we already have the data of all the countries upfront.

Here is a demo of what we will be building in this tutorial.

Demo App Screenshot

Prerequisites

Before getting started, you need to ensure that you have Node already installed on your machine. I will also recommend that you install the Yarn package manager on your machine, since we will be using it for package management instead of npm that ships with Node. You can follow this Yarn installation guide to install yarn on your machine.

We will create the boilerplate code for our React app using the create-react-app command-line package. You also need to ensure that it is installed globally on your machine. If you are using npm >= 5.2 then you may not need to install create-react-app as a global dependency since we can use the npx command.

Finally, this tutorial assumes that you are already familiar with React. If that is not the case, you can check the React Documentation to learn more about React.

Getting Started

Create new Application

Start a new React application using the following command. You can name the application however you desire.

create-react-app react-pagination

npm >= 5.2

If you are using npm version 5.2 or higher, it ships with an additional npx binary. Using the npx binary, you don’t need to install create-react-app globally on your machine. You can start a new React application with this simple command:

npx create-react-app react-pagination

Install Dependencies

Next, we will install the dependencies we need for our application. Run the following command to install the required dependencies.

yarn add bootstrap prop-types react-flags countries-api
yarn add -D npm-run-all node-sass-chokidar

We have installed node-sass-chokidar as a development dependency for our application to enable us use SASS. For more information about this, see this guide.

Now open the src directory and change the file extension of all the .css files to .scss. The required .css files will be compiled by node-sass-chokidar as we continue.

Modify the npm Scripts

Edit the package.json file and modify the scripts section to look like the following:

"scripts": {
  "start:js": "react-scripts start",
  "build:js": "react-scripts build",
  "start": "npm-run-all -p watch:css start:js",
  "build": "npm-run-all build:css build:js",
  "test": "react-scripts test --env=jsdom",
  "eject": "react-scripts eject",
  "build:css": "node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/",
  "watch:css": "npm run build:css && node-sass-chokidar --include-path ./src --include-path ./node_modules src/ -o src/ --watch --recursive"
}

Include Bootstrap CSS

We installed the bootstrap package as a dependency for our application since we will be needing some default styling. We will also be using styles from the Bootstrap pagination component. To include Bootstrap in the application, edit the src/index.js file and add the following line before every other import statement.

import "bootstrap/dist/css/bootstrap.min.css";

Setup Flag Icons

We installed react-flags as a dependency for our application. In order to get access to the flag icons from our application, we will need to copy the icon images to the public directory of our application. Run the following commands from your terminal to copy the flag icons.

mkdir -p public/img
cp -R node_modules/react-flags/vendor/flags public/img

If you are on a Windows machine, run the following commands instead:

mkdir publicimg
xcopy node_modulesreact-flagsvendorflags publicimg /s /e

The components directory

We will create the following React components for our application.

  • CountryCard – This simply renders the name, region and flag of a given country.
  • Pagination – This contains the whole logic for building, rendering and switching pages on the pagination control.

Go ahead and create a components directory inside the src directory of the application to house all our components.

Start the Application

Start the application by running the following command with yarn:

yarn start

The application is now started and development can begin. Notice that a browser tab has been opened for you with live reloading functionality to keep in sync with changes in the application as you develop.

At this point, the application view should look like the following screenshot:

Initial View

The CountryCard Component

Create a new file CountryCard.js in the src/components directory and add the following code snippet to it.

import React from 'react';
import PropTypes from 'prop-types';
import Flag from 'react-flags';

const CountryCard = props => {
  const { cca2: code2 = '', region = null, name = {}  } = props.country || {};

  return (
    <div className="col-sm-6 col-md-4 country-card">
      <div className="country-card-container border-gray rounded border mx-2 my-3 d-flex flex-row align-items-center p-0 bg-light">

        <div className="h-100 position-relative border-gray border-right px-2 bg-white rounded-left">

          <Flag country={code2} format="png" pngSize={64} basePath="./img/flags" className="d-block h-100" />

        </div>

        <div className="px-3">

          <span className="country-name text-dark d-block font-weight-bold">{ name.common }</span>

          <span className="country-region text-secondary text-uppercase">{ region }</span>

        </div>

      </div>
    </div>
  )
}

CountryCard.propTypes = {
  country: PropTypes.shape({
    cca2: PropTypes.string.isRequired,
    region: PropTypes.string.isRequired,
    name: PropTypes.shape({
      common: PropTypes.string.isRequired
    }).isRequired
  }).isRequired
};

export default CountryCard;

The CountryCard component requires a country prop that contains the data about the country to be rendered. As seen in the propTypes for the CountryCard component, the country prop object must contain the following data:

  • cca2 – 2-digit country code
  • region – the country region e.g Africa
  • name.common – the common name of the country e.g Nigeria

Here is a sample country object:

{
  cca2: "NG",
  region: "Africa",
  name: {
    common: "Nigeria"
  }
}

Also notice how we render the country flag using the react-flags package. You can check the react-flags documentation to learn more about the required props and how to use the package.

The Pagination Component

Create a new file Pagination.js in the src/components directory and add the following code snippet to it.

import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';

class Pagination extends Component {

  constructor(props) {
    super(props);
    const { totalRecords = null, pageLimit = 30, pageNeighbours = 0 } = props;

    this.pageLimit = typeof pageLimit === 'number' ? pageLimit : 30;
    this.totalRecords = typeof totalRecords === 'number' ? totalRecords : 0;

    // pageNeighbours can be: 0, 1 or 2
    this.pageNeighbours = typeof pageNeighbours === 'number'
      ? Math.max(0, Math.min(pageNeighbours, 2))
      : 0;

    this.totalPages = Math.ceil(this.totalRecords / this.pageLimit);

    this.state = { currentPage: 1 };
  }

}

Pagination.propTypes = {
  totalRecords: PropTypes.number.isRequired,
  pageLimit: PropTypes.number,
  pageNeighbours: PropTypes.number,
  onPageChanged: PropTypes.func
};

export default Pagination;

The Pagination component can take four special props as specified in the propTypes object.

  • totalRecords – indicates the total number of records to be paginated. It is required.
  • pageLimit – indicates the number of records to be shown per page. If not specified, it defaults to 30 as defined in the constructor().
  • pageNeighbours – indicates the number of additional page numbers to show on each side of the current page. The minimum value is 0 and the maximum value is 2. If not specified, it defaults to 0 as defined in the constructor(). The following image illustrates the effect of different values of the pageNeighbours prop:

Page Neighbours Illustration

  • onPageChanged – is a function that will be called with data of the current pagination state only when the current page changes.

In the constructor() function, we compute the total pages as follows:

this.totalPages = Math.ceil(this.totalRecords / this.pageLimit);

Notice that we use Math.ceil() here to ensure that we get an integer value for the total number of pages. This also ensures that the excess records are captured in the last page, especially in cases where the number of excess records is less than the number of records to be shown per page.

Finally, we initialize the state with the currentPage property set to 1. We need this state property to internally keep track of the currently active page.

Next, we will go ahead and create the method for generating the page numbers. Modify the Pagination component as shown in the following code snippet:

const LEFT_PAGE = 'LEFT';
const RIGHT_PAGE = 'RIGHT';

/**
 * Helper method for creating a range of numbers
 * range(1, 5) => [1, 2, 3, 4, 5]
 */
const range = (from, to, step = 1) => {
  let i = from;
  const range = [];

  while (i <= to) {
    range.push(i);
    i += step;
  }

  return range;
}

class Pagination extends Component {

  /**
   * Let's say we have 10 pages and we set pageNeighbours to 2
   * Given that the current page is 6
   * The pagination control will look like the following:
   *
   * (1) < {4 5} [6] {7 8} > (10)
   *
   * (x) => terminal pages: first and last page(always visible)
   * [x] => represents current page
   * {...x} => represents page neighbours
   */
  fetchPageNumbers = () => {

    const totalPages = this.totalPages;
    const currentPage = this.state.currentPage;
    const pageNeighbours = this.pageNeighbours;

    /**
     * totalNumbers: the total page numbers to show on the control
     * totalBlocks: totalNumbers + 2 to cover for the left(<) and right(>) controls
     */
    const totalNumbers = (this.pageNeighbours * 2) + 3;
    const totalBlocks = totalNumbers + 2;

    if (totalPages > totalBlocks) {

      const startPage = Math.max(2, currentPage - pageNeighbours);
      const endPage = Math.min(totalPages - 1, currentPage + pageNeighbours);

      let pages = range(startPage, endPage);

      /**
       * hasLeftSpill: has hidden pages to the left
       * hasRightSpill: has hidden pages to the right
       * spillOffset: number of hidden pages either to the left or to the right
       */
      const hasLeftSpill = startPage > 2;
      const hasRightSpill = (totalPages - endPage) > 1;
      const spillOffset = totalNumbers - (pages.length + 1);

      switch (true) {
        // handle: (1) < {5 6} [7] {8 9} (10)
        case (hasLeftSpill && !hasRightSpill): {
          const extraPages = range(startPage - spillOffset, startPage - 1);
          pages = [LEFT_PAGE, ...extraPages, ...pages];
          break;
        }

        // handle: (1) {2 3} [4] {5 6} > (10)
        case (!hasLeftSpill && hasRightSpill): {
          const extraPages = range(endPage + 1, endPage + spillOffset);
          pages = [...pages, ...extraPages, RIGHT_PAGE];
          break;
        }

        // handle: (1) < {4 5} [6] {7 8} > (10)
        case (hasLeftSpill && hasRightSpill):
        default: {
          pages = [LEFT_PAGE, ...pages, RIGHT_PAGE];
          break;
        }
      }

      return [1, ...pages, totalPages];

    }

    return range(1, totalPages);

  }

}

Here, we first define two constants: LEFT_PAGE and RIGHT_PAGE. These constants will be used to indicate points where we have page controls for moving left and right respectively.

We also define a helper range() function that can help us generate ranges of numbers. If you use a utility library like Lodash in your project, then you can use the _.range() function provided by Lodash instead. The following code snippet shows the difference between the range() function we just defined and the one from Lodash:

range(1, 5); // returns [1, 2, 3, 4, 5]
_.range(1, 5); // returns [1, 2, 3, 4]

Next, we define the fetchPageNumbers() method in the Pagination class. This method handles the core logic for generating the page numbers to be shown on the pagination control. We want the first page and last page to always be visible.

First, we define a couple of variables. totalNumbers represents the total page numbers that will be shown on the control. totalBlocks represents the total page numbers to be shown plus two additional blocks for left and right page indicators.

If totalPages is not greater than totalBlocks, we simply return a range of numbers from 1 to totalPages. Otherwise, we return the array of page numbers, with LEFT_PAGE and RIGHT_PAGE at points where we have pages spilling to the left and right respectively.

Notice however that our pagination control ensures that the first page and last page are always visible. The left and right page controls appear inwards.

Now we will go ahead and add the render() method to enable us render the pagination control. Modify the Pagination component as shown in the following snippet:

class Pagination extends Component {

render() {

    if (!this.totalRecords || this.totalPages === 1) return null;

    const { currentPage } = this.state;
    const pages = this.fetchPageNumbers();

    return (
      <Fragment>
        <nav aria-label="Countries Pagination">
          <ul className="pagination">
            { pages.map((page, index) => {

              if (page === LEFT_PAGE) return (
                <li key={index} className="page-item">
                  <a className="page-link" href="#" aria-label="Previous" onClick={this.handleMoveLeft}>
                    <span aria-hidden="true">&laquo;</span>
                    <span className="sr-only">Previous</span>
                  </a>
                </li>
              );

              if (page === RIGHT_PAGE) return (
                <li key={index} className="page-item">
                  <a className="page-link" href="#" aria-label="Next" onClick={this.handleMoveRight}>
                    <span aria-hidden="true">&raquo;</span>
                    <span className="sr-only">Next</span>
                  </a>
                </li>
              );

              return (
                <li key={index} className={`page-item${ currentPage === page ? ' active' : ''}`}>
                  <a className="page-link" href="#" onClick={ this.handleClick(page) }>{ page }</a>
                </li>
              );

            }) }

          </ul>
        </nav>
      </Fragment>
    );

  }

}

Here, we generate the page numbers array by calling the fetchPageNumbers() method we created earlier. We then render each page number using Array.prototype.map(). Notice that we register click event handlers on each rendered page number to handle clicks.

Also notice that the pagination control will not be rendered if the totalRecords prop was not passed in correctly to the Pagination component or in cases where there is only 1 page.

Finally, we will define the event handler methods. Modify the Pagination component as shown in the following snippet:

class Pagination extends Component {

  componentDidMount() {
    this.gotoPage(1);
  }

  gotoPage = page => {
    const { onPageChanged = f => f } = this.props;

    const currentPage = Math.max(0, Math.min(page, this.totalPages));

    const paginationData = {
      currentPage,
      totalPages: this.totalPages,
      pageLimit: this.pageLimit,
      totalRecords: this.totalRecords
    };

    this.setState({ currentPage }, () => onPageChanged(paginationData));
  }

  handleClick = page => evt => {
    evt.preventDefault();
    this.gotoPage(page);
  }

  handleMoveLeft = evt => {
    evt.preventDefault();
    this.gotoPage(this.state.currentPage - (this.pageNeighbours * 2) - 1);
  }

  handleMoveRight = evt => {
    evt.preventDefault();
    this.gotoPage(this.state.currentPage + (this.pageNeighbours * 2) + 1);
  }

}

We define the gotoPage() method that modifies the state and sets the currentPage to the specified page. It ensures that the page argument has a minimum value of 1 and a maximum value of the total number of pages. It finally calls the onPageChanged() function that was passed in as prop, with data indicating the new pagination state.

When the component mounts, we simply go to the first page by calling this.gotoPage(1) as shown in the componentDidMount() lifecycle method.

Notice how we use (this.pageNeighbours * 2) in handleMoveLeft() and handleMoveRight() to slide the page numbers to the left and to the right respectively based on the current page number.

Here is a demo of the interaction we have been able to achieve:

Left-Right Movement

The App Component

We will go ahead and modify the App.js file in the src directory. The App.js file should look like the following snippet:

import React, { Component } from 'react';
import Countries from 'countries-api';
import './App.css';

import Pagination from './components/Pagination';
import CountryCard from './components/CountryCard';

class App extends Component {

  state = { allCountries: [], currentCountries: [], currentPage: null, totalPages: null }

  componentDidMount() {
    const { data: allCountries = [] } = Countries.findAll();
    this.setState({ allCountries });
  }

  onPageChanged = data => {
    const { allCountries } = this.state;
    const { currentPage, totalPages, pageLimit } = data;

    const offset = (currentPage - 1) * pageLimit;
    const currentCountries = allCountries.slice(offset, offset + pageLimit);

    this.setState({ currentPage, currentCountries, totalPages });
  }

}

export default App;

Here we initialize the App component’s state with the following attributes:

  • allCountries – an array of all the countries in our app. Initialized to an empty array([]).
  • currentCountries – an array of all the countries to be shown on the currently active page. Initialized to an empty array([]).
  • currentPage – the page number of the currently active page. Initialized to null.
  • totalPages – the total number of pages for all the country records. Initialized to null.

Next, in the componentDidMount() lifecycle method, we fetch all the world countries using the countries-api package by invoking Countries.findAll(). We then update the app state, setting allCountries to contain all the world countries. You can see the countries-api documentation to learn more about the package.

Finally, we defined the onPageChanged() method, which will be called each time we navigate to a new page from the pagination control. This method will be passed to the onPageChanged prop of the Pagination component.

There are two lines that are worth paying attention to in this method. The first is this line:

const offset = (currentPage - 1) * pageLimit;

The offset value indicates the starting index for fetching the records for the current page. Using (currentPage - 1) ensures that the offset is zero-based. Let’s say for example that we are displaying 25 records per page and we are currently viewing page 5. Then the offset will be ((5 - 1) * 25 = 100).

In fact, if you are fetching records on demand from a database for example, this is a sample SQL query to show you how offset can be used:

SELECT * FROM `countries` LIMIT 100, 25

Since, we are not fetching records on demand from a database or any external source, we need a way to extract the required chunk of records to be shown for the current page. That brings us to the second line:

const currentCountries = allCountries.slice(offset, offset + pageLimit);

Notice here that we use the Array.prototype.slice() method to extract the required chunk of records from allCountries by passing the offset as the starting index for the slice and (offset + pageLimit) as the index before which to end the slice.

Fetching records in real applications

In order to keep this tutorial as simple as possible, we did not fetch records from any external source. In a real application, you will probably be fetching records from a database or an API. The logic for fetching the records can easily go into the onPageChanged() method of the App component.

Let’s say we have a fictitious API endpoint /api/countries?page={current_page}&limit={page_limit}. The following snippet shows how we can fetch countries on demand from the API using the axios HTTP package:

onPageChanged = data => {
 const { currentPage, totalPages, pageLimit } = data;

 axios.get(`/api/countries?page=${currentPage}&limit=${pageLimit}`)
   .then(response => {
     const currentCountries = response.data.countries;
     this.setState({ currentPage, currentCountries, totalPages });
   });
}

Now we will go ahead and finish up the App component by adding the render() method. Modify the App component accordingly as shown in the following snippet:

class App extends Component {

  // other methods here ...

  render() {

    const { allCountries, currentCountries, currentPage, totalPages } = this.state;
    const totalCountries = allCountries.length;

    if (totalCountries === 0) return null;

    const headerClass = ['text-dark py-2 pr-4 m-0', currentPage ? 'border-gray border-right' : ''].join(' ').trim();

    return (
      <div className="container mb-5">
        <div className="row d-flex flex-row py-5">

          <div className="w-100 px-4 py-5 d-flex flex-row flex-wrap align-items-center justify-content-between">
            <div className="d-flex flex-row align-items-center">

              <h2 className={headerClass}>
                <strong className="text-secondary">{totalCountries}</strong> Countries
              </h2>

              { currentPage && (
                <span className="current-page d-inline-block h-100 pl-4 text-secondary">
                  Page <span className="font-weight-bold">{ currentPage }</span> / <span className="font-weight-bold">{ totalPages }</span>
                </span>
              ) }

            </div>

            <div className="d-flex flex-row py-4 align-items-center">
              <Pagination totalRecords={totalCountries} pageLimit={18} pageNeighbours={1} onPageChanged={this.onPageChanged} />
            </div>
          </div>

          { currentCountries.map(country => <CountryCard key={country.cca3} country={country} />) }

        </div>
      </div>
    );

  }

}

The render() method is quite straightforward. We render the total number of countries, the current page, the total number of pages, the <Pagination> control and then the <CountryCard> for each country in the current page.

Notice that we passed the onPageChanged() method we defined earlier to the onPageChanged prop of the <Pagination> control. This is very important for capturing page changes from the Pagination component. Also notice that we are displaying 18 countries per page.

At this point, the app should look like the following screenshot:

App Screenshot

Levelling up with some styles

You would have noticed that we have been adding some custom classes to the components we created earlier. We will go ahead and define some style rules for those classes in the src/App.scss file. The App.scss file should look like the following snippet:

/* Declare some variables */
$base-color: #ced4da;
$light-background: lighten(desaturate($base-color, 50%), 12.5%);

.current-page {
  font-size: 1.5rem;
  vertical-align: middle;
}

.country-card-container {
  height: 60px;
  cursor: pointer;
  position: relative;
  overflow: hidden;
}

.country-name {
  font-size: 0.9rem;
}

.country-region {
  font-size: 0.7rem;
}

.current-page,
.country-name,
.country-region {
  line-height: 1;
}

// Override some Bootstrap pagination styles
ul.pagination {
  margin-top: 0;
  margin-bottom: 0;
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);

  li.page-item.active {
    a.page-link {
      color: saturate(darken($base-color, 50%), 5%) !important;
      background-color: saturate(lighten($base-color, 7.5%), 2.5%) !important;
      border-color: $base-color !important;
    }
  }

  a.page-link {
    padding: 0.75rem 1rem;
    min-width: 3.5rem;
    text-align: center;
    box-shadow: none !important;
    border-color: $base-color !important;
    color: saturate(darken($base-color, 30%), 10%);
    font-weight: 900;
    font-size: 1rem;

    &:hover {
      background-color: $light-background;
    }
  }
}

After adding the styles, the app should now look like the following screenshot:

App Screenshot with Styles

Conclusion

In this tutorial, we have been able to create a custom pagination widget in our React application. Although we didn’t make calls to any API or interact with any database back-end in this tutorial, it is possible that your application may demand such interactions. You are not in any way limited to the approach used in this tutorial – you can extend it as you wish to suite the requirements of your application.

For the complete sourcecode of this tutorial, checkout the build-react-pagination-demo repository on Github. You can also get a live demo of this tutorial on Code Sandbox.

Source: Scotch.io

0 0 votes
Article Rating
Subscribe
Notify of
guest

0 Comments
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x