Making pagination in a React app

We write a simple, reusable paginator for a React application in typescript. We cover it with Jest tests.

Action plan

The entire action plan will consist of 5 consecutive stages:

  1. Initializing the Application

  2. We write a container component and define the data retrieval logic

  3. We write our own paginator

  4. Putting it all together

  5. Writing tests for our component

So let’s go!

Application initialization

Minimum action: take create-react-app with the typescript template and deploy the application.

  npx create-react-app my-app --template typescript

How we go for data

The data will be stored in the container component. It will keep track of the state, call the api method and push the updated data down (to our future component).

We will traditionally pull the data using the hook useEffectand save the data with useState.

import React, { useEffect, useState, useCallback } from 'react';

import api from './api';
import type { RESPONSE_DATA } from './api';

import './App.css';

function App() {
  const [data, setData] = useState<RESPONSE_DATA | null>(null);
  const [page, setPage] = useState(1);
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await api.get.data(page);
        setData(response);
      } catch (err) {
        setError(
          err instanceof Error ? err.message : 'Unknown Error: api.get.data'
        );
        setData(null);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [page]);

  return <div className="App">...</div>;
}

export default App;

Api module can be anything, but if you are too lazy to invent, then you can see the approximate implementation in my other article: Github pages for pet projects in the section API module.

And about typing catch block in typescript can be read Here.

Writing a Component

We already have a container, now let’s write a simple visual Stateless component.

Properties

First, let’s define what exactly our paginator should do.

Our component should:

  • be able to notify the parent component that a pagination event has occurred

  • be able to disable toggle buttons in boundary conditions

  • be able to display our current position among all available pages

The last point becomes relevant if the api does not provide information about the final number of elements. This can easily happen when the database is very dynamic and constantly changing.

Let’s translate all our requirements into typescript and describe the interface for interacting with our component:

type PaginationProps = {
  onNextPageClick: () => void;
  onPrevPageClick: () => void;
  disable: {
    left: boolean;
    right: boolean;
  };
  nav?: {
    current: number;
    total: number;
  };
};

Stylization

For styling, we will use css modules for styling (since the application is based on react-create-app with the ts template, we have already implemented css modules support out of the box).
We just need to import the styles and apply them to the elements:

  import Styles from './index.module.css';
  ...

  <div className={Styles.paginator}>...</div>

Layout

The render component itself will be a very trivial set of two buttons and a navigation block. Navigation will be “hidden” behind conditional rendering.
To optimize, wrap the component in React.memo

import React from 'react';

import Styles from './index.module.css';

type PaginationProps = {
  onNextPageClick: () => void;
  onPrevPageClick: () => void;
  disable: {
    left: boolean;
    right: boolean;
  };
  nav?: {
    current: number;
    total: number;
  };
};

const Pagination = (props: PaginationProps) => {
  const { nav = null, disable, onNextPageClick, onPrevPageClick } = props;

  const handleNextPageClick = () => {
    onNextPageClick();
  };
  const handlePrevPageClick = () => {
    onPrevPageClick();
  };

  return (
    <div className={Styles.paginator}>
      <button
        className={Styles.arrow}
        type="button"
        onClick={handlePrevPageClick}
        disabled={disable.left}
        data-testid="pagination-prev-button"
      >
        {'<'}
      </button>
      {nav && (
        <span className={Styles.navigation} data-testid="pagination-navigation">
          {nav.current} / {nav.total}
        </span>
      )}
      <button
        className={Styles.arrow}
        type="button"
        onClick={handleNextPageClick}
        disabled={disable.right}
        data-testid="pagination-next-button"
      >
        {'>'}
      </button>
    </div>
  );
};

export default React.memo(Pagination);

Connecting container and paginator

We write handlers and pass the state to the paginator component.

const ROWS_PER_PAGE = 10;

const getTotalPageCount = (rowCount: number): number =>
  Math.ceil(rowCount / ROWS_PER_PAGE);

const handleNextPageClick = useCallback(() => {
  const current = page;
  const next = current + 1;
  const total = data ? getTotalPageCount(data.count) : current;

  setPage(next <= total ? next : current);
}, [page, data]);

const handlePrevPageClick = useCallback(() => {
  const current = page;
  const prev = current - 1;

  setPage(prev > 0 ? prev : current);
}, [page]);

The handlers contain the logic that will ultimately determine which page we will render. This, in turn, will already trigger a data request and a change in the state of the paginator.

Total

It remains only to connect our component Pagination and our container component:

import React, { useEffect, useState, useCallback } from 'react';

import api from './api';
import type { RESPONSE_DATA } from './api';

import Pagination from './components/pagination';

import './App.css';

const ROWS_PER_PAGE = 10;

const getTotalPageCount = (rowCount: number): number =>
  Math.ceil(rowCount / ROWS_PER_PAGE);

function App() {
  const [data, setData] = useState<RESPONSE_DATA | null>(null);
  const [page, setPage] = useState(1);
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await api.get.data(page);
        setData(response);
      } catch (err) {
        setError(
          err instanceof Error ? err.message : 'Unknown Error: api.get.data'
        );
        setData(null);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [page]);

  const handleNextPageClick = useCallback(() => {
    const current = page;
    const next = current + 1;
    const total = data ? getTotalPageCount(data.count) : current;

    setPage(next <= total ? next : current);
  }, [page, data]);

  const handlePrevPageClick = useCallback(() => {
    const current = page;
    const prev = current - 1;

    setPage(prev > 0 ? prev : current);
  }, [page]);

  return (
    <div className="App">
      {data?.list ? (
        <ul>
          {data.list.map((item, index) => (
            <li key={index}>{`${item.name}`}</li>
          ))}
        </ul>
      ) : (
        'no data'
      )}
      {data && (
        <Pagination
          onNextPageClick={handleNextPageClick}
          onPrevPageClick={handlePrevPageClick}
          disable={{
            left: page === 1,
            right: page === getTotalPageCount(data.count),
          }}
          nav={{ current: page, total: getTotalPageCount(data.count) }}
        />
      )}
    </div>
  );
}

export default App;

We are done with logic. Our component can both change the state of the container and respond to a change in this state. We also provided a mode of operation without navigation.

The only thing left to do is to write a couple of tests and gain final confidence in our component when it is reused)

We cover with tests

Our component is quite simple, so we will only test 3 aspects of our component:

  • call onClick handlers when clicking on the arrows

  • prostavlenie disable attributes on paginator arrows in boundary states

  • correct work conditional rendering navigation

Test Structure

In general, each test will be organized according to the following algorithm:

  • render component

  • we are looking for the component element we need

  • perform an action: click, call a function, or something else

  • we check

The method is responsible for rendering render. Method screen will help us find the elements after rendering. In our case, we will use screen.getByTestId()
And the methods fireEvent will give us the ability to simulate the events of a real user.

All these objects are taken from @testing-library:

import { render, fireEvent, screen } from '@testing-library/react';

More details can be found in the examples and documentation. @testing-library/react

PS:
We already have all the initial settings for running tests out of the box create-react-app

Adding Test Attributes

In order for us to identify our elements in the test, there is a good way – search by attribute.
In fact, there are a lot of ways (search by role, text, etc.), but for simplicity and clarity, we will use attributes.

So, we add an attribute to the elements we need data-testid with a unique value.
It is desirable that the attribute value be unique not only within the component, but also within any context where it (the component) will be applied.

...
const Pagination = (props: PaginationProps) => {
  ...
  return (
    <div className={Styles.paginator}>
      <button
        className={Styles.arrow}
        ...
        data-testid="pagination-prev-button"
      >
        {'<'}
      </button>
      {nav && (
        <span className={Styles.navigation} data-testid="pagination-navigation">
          {nav.current} / {nav.total}
        </span>
      )}
      <button
        className={Styles.arrow}
        ...
        data-testid="pagination-next-button"
      >
        {'>'}
      </button>
    </div>
  );
};

export default React.memo(Pagination);

Testing disabled attribute setting

import '@testing-library/jest-dom';
import { render, fireEvent, screen } from '@testing-library/react';

import Pagination from '../../src/components/pagination';

describe('React component: Pagination', () => {
  it('Должен проставляться атрибут [disabled] для кнопки "назад", если выбрана первая страница', async () => {
    render(
      <Pagination
        disable={{
          left: true,
          right: false,
        }}
        onPrevPageClick={jest.fn()}
        onNextPageClick={jest.fn()}
      />
    );

    const prevButton = screen.getByTestId('pagination-prev-button');
    expect(prevButton).toHaveAttribute('disabled');
  });

  it('Должен проставляться атрибут [disabled] для кнопки "вперёд", если выбрана последняя страница', async () => {
    render(
      <Pagination
        disable={{
          left: false,
          right: true,
        }}
        onPrevPageClick={jest.fn()}
        onNextPageClick={jest.fn()}
      />
    );

    const nextButton = screen.getByTestId('pagination-next-button');
    expect(nextButton).toHaveAttribute('disabled');
  });
});

Testing Conditional Navigation Rendering

We need a method toThrowand in expect itself we will pass a function, not a variable.

describe('React component: Pagination', () => {
  it('Должен проставляться атрибут [disabled] для кнопки "назад", если выбрана первая страница', async () => {...});
  it('Должен проставляться атрибут [disabled] для кнопки "вперёд", если выбрана последняя страница', async () => {...});

  it('Не должна отображаться навигация "<текущая страница>/<все страницы>" если не предоставлен соответствующий пропс "nav"', async () => {
    render(
      <Pagination
        disable={{
          left: false,
          right: false,
        }}
        onPrevPageClick={jest.fn()}
        onNextPageClick={jest.fn()}
      />
    );

    expect(() => screen.getByTestId('pagination-navigation')).toThrow();
  });
});

Testing the work of callbacks

Here we need to use the method toHaveBeenCalledTimes

import '@testing-library/jest-dom';
import { render, fireEvent, screen } from '@testing-library/react';

import Pagination from '../../src/components/pagination';

describe('React component: Pagination', () => {
  it('Должен проставляться атрибут [disabled] для кнопки "назад", если выбрана первая страница', async () => {...});
  it('Должен проставляться атрибут [disabled] для кнопки "вперёд", если выбрана последняя страница', async () => {...});
  it('Не должна отображаться навигация "<текущая страница>/<все страницы>" если не предоставлен соответствующий пропс "nav"', async () => {...});

  it('Должен вызываться обработчик "onPrevPageClick" при клике на кнопку "назад"', async () => {
    const onPrevPageClick = jest.fn();

    render(
      <Pagination
        disable={{
          left: false,
          right: false,
        }}
        onPrevPageClick={onPrevPageClick}
        onNextPageClick={jest.fn()}
      />
    );

    const prevButton = screen.getByTestId('pagination-prev-button');
    fireEvent.click(prevButton);

    expect(onPrevPageClick).toHaveBeenCalledTimes(1);
  });

  it('Должен вызываться обработчик "onNextPageClick" при клике на кнопку "вперёд"', async () => {
    const onNextPageClick = jest.fn();

    render(
      <Pagination
        disable={{
          left: false,
          right: false,
        }}
        onPrevPageClick={jest.fn()}
        onNextPageClick={onNextPageClick}
      />
    );

    const nextButton = screen.getByTestId('pagination-next-button');
    fireEvent.click(nextButton);

    expect(onNextPageClick).toHaveBeenCalledTimes(1);
  });
});

Total

Thanks for reading and good luck implementing the pagination feature)

PS: Links from the article:

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *