BlogReactJS

React pagination component with all the code explained

Written by Codemzy on September 1st, 2023

Here's how I built a reusable pagination component with ReactJS, with next and previous buttons, that works with or without knowing the total number of pages available.

Sometimes, you'll have a long list of data spread across multiple pages - because no one wants (or needs) 10,000 items at once (and your browser won't like it either!).

Pagination components are used where you have a list of content over multiple pages. Like a contact list, a list of results, or a list of blog posts.

I use one on this blog builder - it looks like this:

codemzy blog pagination example

I built that pagination component with React even though this is a static site.

I love building reusable React components (like the dropdown component we made recently). They are super rewarding because you can use them again and again in your projects. And every time you get to reuse it, you feel like you've saved yourself a bunch of time.

So with that in mind, let's take on our next React component challenge. Pagination!

Here's what we will be building:

See the Pen Untitled by Codemzy (@codemzy) on CodePen.

And here's how we will build it:

  1. Create a Button component for the pages
  2. Create a Pagination component
  3. Add the current page button
  4. Add a next button
  5. Add a previous button
  6. Disable buttons when they are not active
  7. Add buttons for pages (optional)

We want our Pagination component to be reusable, so it will need to:

  • Show the current page number
  • Have previous and next buttons to navigate to different pages
  • Tell the parent component when the page changes (so it's reusable for different use cases)
  • Show other pages (if we know them)
  • Allow the user to click to navigate to specific pages
  • Not show other pages (if the pages are unknown)

Ok. We have our spec - let's go!

1. Create a Button component for the pages

A pagination component is often a bunch of buttons or links. You have a previous button, a bunch of pages, and a next button.

In HTML these would be a elements, but in your React app, the pager could do many things (not just change a page).

You might want to change a page, but more likely, you would want to update some state and show the next set of results or request the next page of data from your server.

For my reusable pagination component, I'm going to use buttons* as the default element for my pages. This means:

  • The pagination component won't rely on installing a specific library or router
  • The pagination can do more than change a page - for example, load more data, call a function, or both!

*You can use the "as" prop if you want to be able to switch the type of element or component returned.

So let's start with a Button component for our pagination.

// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button {...props}>{children}</button>
  );
};

That looks simple enough to start with. The Button component takes a children prop - so it can be used like <Button>{1}</Button> to pass it the number 1 as the children prop.

All other ...props get passed on to the button <button {...props}>. That's not doing anything for now - we will use this later when we need onClick handlers and disabled attributes. The Button component doesn't need to know what it's going to do when it's clicked, because each time we use the button it might need to do something different. Like move to the next page. Or move to previous page. Or move to a specific page. So we will pass this function as a prop.

I'm going to add a bunch of TailwindCSS classes to make my buttons look good too.

// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button className="inline-block py-3 px-4 mx-px text-center leading-none rounded hover:bg-gray-100 focus:bg-gray-100" {...props}>{children}</button>
  );
};

TailwindCSS is optional, you can remove all the tailwind classes if you don't use it, and do something like className="pagination-button" and then write your own CSS. If you are not familiar with any of the TailwindCSS classes I use in this guide, check out the TailwindCSS docs (they are excellent!).

Now we can use the Button component like this:

<div className="p-5 flex items-center justify-center text-gray-600">
 <Button>{1}</Button>
 <Button>{2}</Button>
 <Button>{3}</Button>
 <Button>{4}</Button>
 </div>
<div id="app"></div>
// button component for the pagination
function Button({ children, ...props }) {
 return (
  <button className="inline-block py-3 px-4 min-w-10 text-center leading-none rounded hover:bg-gray-100 focus:bg-gray-100" {...props}>{children}</button>
 );
};


// pagination component
function Pagination({ page, pages }) {
 return (
  <div className="p-5 flex items-center justify-center text-gray-600">
  <Button>{1}</Button>
  <Button>{2}</Button>
  <Button>{3}</Button>
  <Button>{4}</Button>
  </div>
 );
};


// for displaying the pagination component
function App() {
 return (
  <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
  <Pagination />
  </div>
 );
};


// ========================================
// render the react app
ReactDOM.render(
 <App />,
 document.getElementById('app')
);

It's already starting to look like pagination! So let's build the Pagination component.

2. Create a Pagination component

To start with, the Pagination component will accept two props, page and setPage.

// pagination component
function Pagination({ page, setPage }) {
  return (
    // return the pager
  );
};

You might have been expecting the component to store this state (with useState), but we don't want that. We want our pagination component to be as dumb as possible.

Why we don't want the page state in our component

Our Pagination component doesn't need to know this:

// state
const [page, setPage] = React.useState(1);

// do something when the page changes
React.useEffect(() => {
  // go get another page of results
}, [page]);

We don't want our pagination component to be in charge of fetching the next page of results for example, because then it can't be used by other components, or for other data.

Instead, we can lift the state up, and the component that is using the Pagination component can call the server (or whatever it needs to do).

function ResultsData({ data }) {
  // state
  const [page, setPage] = React.useState(1);

  // get more results when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);

  return (
    <div>
      <ResultsList data={data} />
      <Pagination page={page} setPage={setPage} />
    </div>
  );
};

The less the component controls, the more reusable it is, so we need to lift this state out of the component and let the parent handle it.

3. Add the current page button

So let's get back to our dumb (but soon-to-be very useful) Pagination component, and now that we are passing a page prop, we can display the current page.

// pagination component
function Pagination({ page, setPage }) {
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button>{page}</Button>
    </div>
  );
};

Again, I'm using TailwindCSS to add some padding and make sure my buttons will line up.

4. Add a next button

For our pagination to work, we need to be able to move off the first page! We'll add a next button for that.

<Button>Next</Button>

And we will need to pass it an onClick function to actually move to the next page.

// pagination component
function Pagination({ page, setPage }) {
  
  // 🆕 handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button active={true}>{page}</Button>
      <Button onClick={handleNext}>Next</Button>
    </div>
  );
};

And here it is in action!

<div id="app"></div>
// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button className="inline-block py-3 px-4 min-w-12 text-center leading-none rounded hover:bg-gray-100 focus:bg-gray-100" {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button>{page}</Button>
      <Button onClick={handleNext}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

That's working great, but I don't like how the button width changes as the page number changes. It makes the next button jump around a bit.

I'm going to update my button css to give it a fixed width if it's a number (and reduce the padding on the left and right), so our numbers fit well within the same shape button up to the 1000s ${ typeof children === "number" ? "w-12 px-1" : "px-4"}.

// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded bg-gray-50 hover:bg-gray-100 focus:bg-gray-100 ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};

I've also given the buttons a light grey background bg-gray-50 so I can see the separate buttons a little better (this styling is optional!).

<div id="app"></div>
// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded bg-gray-50 hover:bg-gray-100 focus:bg-gray-100 ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button>{page}</Button>
      <Button onClick={handleNext}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

We will limit how high we can go with our next button shortly.

5. Add a previous button

Now we can get ourselves off page 1 (with our next button), but we also want to be able to get back to it. We need a previous button for that!

It's a very similar button and handler, but instead of going up a page, we'll go down instead!

// pagination component
function Pagination({ page, setPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // 🆕 handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious}>Previous</Button>
      <Button>{page}</Button>
      <Button onClick={handleNext}>Next</Button>
    </div>
  );
};
<div id="app"></div>
// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded bg-gray-50 hover:bg-gray-100 focus:bg-gray-100 ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious}>Previous</Button>
      <Button>{page}</Button>
      <Button onClick={handleNext}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

You might have noticed that we can go down to page zero and below, which of course, we don't want... let's fix that.

6. Disable buttons when they are not active

If we are on page 1, we shouldn't be able to use the previous button. And if we are on the last page, we don't want to be able to use the next button either.

But we can't expect our users to know that!

It would be good if we could disable the buttons when they can't be used so that our users know they are out of action (and we don't make extra unnecessary calls to the server).

And we can!

We just need to pass a disabled prop through with a true to disable the button.

I want to disable:

  • The previous button if we are on the first page
  • The next button if we are on the last page

Disabling the previous button

Our page numbers start at 1 (the first page). So if we are on page 1, we want to disable the previous button.

Here's how we can do that.

<Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>

disabled={page <= 1} lets our button know that it is disabled if the page number is 1 or below. To let our users know, let's style the button so it looks different if it's disabled.

I'm going to update the styles on my Button component from hover:bg-gray-100 focus:bg-gray-100 to enabled:hover:bg-gray-100 enabled:focus:bg-gray-100 disabled:opacity-50, so that the hover and focus styles will only apply if the button is not disabled, and if it is disabled, it's faded to 50% opacity.

<div id="app"></div>
// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded bg-gray-50 enabled:hover:bg-gray-100 enabled:focus:bg-gray-100 disabled:opacity-50 ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
      <Button>{page}</Button>
      <Button onClick={handleNext}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

Disabling the next button

Disabling the previous button is easy, if we are on page 1, there is no previous page. But disabling the next page button is not so easy.

When is the last page?

For my data, I solved this on the server by returning a hasNextPage variable with the paginated data.

// example data
let results = {
  data: [ ... ],
  hasNextPage: false,
};
<Button disabled={!hasNextPage}>Next</Button>

If hasNextPage is false, and we don't have a next page to go to, we want disabled to be true. hasNextPage will be false, so !hasNextPage will make it true for our disabled attribute.

Depending on your data, you may be able to create a similar variable or if you are using an external API, they might return something similar. For example, the Stripe API returns a has_more property letting you know if there's more data to fetch.

Let's add a hasNextPage prop to our Pagination component.

// pagination component
function Pagination({ page, setPage, hasNextPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
      <Button active={true}>{page}</Button>
      <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
    </div>
  );
};

For this example, since I'm not using any real data, I'll say the last page is 12.

function ResultsData({ data }) {
  // state
  const [page, setPage] = React.useState(1);
  const pages = 12; // dummy data for example

  // get more results when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);

  return (
    <div>
      <ResultsList data={data} />
      <Pagination page={page} setPage={setPage} hasNextPage={page < pages} />
    </div>
  );
};
<div id="app"></div>
// button component for the pagination
function Button({ children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded bg-gray-50 enabled:hover:bg-gray-100 enabled:focus:bg-gray-100 disabled:opacity-50 ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage, hasNextPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
      <Button>{page}</Button>
      <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  const pages = 12; // dummy data for example
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} hasNextPage={page < pages} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

7. Add buttons for pages (optional)

Our Pagination component is working pretty well. It tells us what page we are on, and we can use the next and previous buttons to change pages. And sometimes, that's all you need.

But let's take things a step further, and add more pages so that if a user is on page 5, for example, they can click on page 1 instead of having to click 4 times on the previous button to get there!

For this, we need to add a pages prop to our Pagination component, because we can only show more pages if we know how many pages we can show. It's no good showing a page 3 if there is no page 3!

// pagination component
function Pagination({ page, setPage, pages, hasNextPage }) {
  // ...

I'm going to make everything to do with pages optional so that our pagination works with or without knowing the number of pages - because sometimes the server/API we use might not tell us the total number of pages (just if there is a next page). That's why I'm keeping the hasNextPage prop separate.

Adding the next pages

Let's start by showing a page 2 button.

We only want to show a page 2 if there is a page 2 (page + 1 <= pages) and then we can show a page 2 button. I'm not going to hardcode the 2 because, if our current page is page 2, then this button will be 3 etc.

{ page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }

Here's how that looks in the Pagination component.

return (
  <div className="p-5 flex items-center justify-center text-gray-600">
    <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
    <Button>{page}</Button>
    { page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }
    <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
  </div>
);

I want to show up to 5 pages at a time, so I'll do the same for the next 4 pages.

return (
  <div className="p-5 flex items-center justify-center text-gray-600">
    <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
    <Button active={true}>{page}</Button>
    { page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }
    { page + 2 <= pages && <Button onClick={() => setPage(page + 2)}>{page + 2}</Button> }
    { page + 3 <= pages && <Button onClick={() => setPage(page + 3)}>{page + 3}</Button> }
    { page + 4 <= pages && <Button onClick={() => setPage(page + 4)}>{page + 4}</Button> }
    <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
  </div>
);

And to make our pagination a little clearer, I've added active={true} to the current page, passed that prop to the Button component and styled it differently so it's clear what page we are on:

<div id="app"></div>
// button component for the pagination
function Button({ active, children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded ${ active ? "text-blue-500 font-bold" : "bg-gray-50 enabled:hover:bg-gray-100 disabled:opacity-50" } ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage, pages, hasNextPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
      <Button active={true}>{page}</Button>
      { page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }
      { page + 2 <= pages && <Button onClick={() => setPage(page + 2)}>{page + 2}</Button> }
      { page + 3 <= pages && <Button onClick={() => setPage(page + 3)}>{page + 3}</Button> }
      { page + 4 <= pages && <Button onClick={() => setPage(page + 4)}>{page + 4}</Button> }
      <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  const pages = 12; // dummy data for example
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} hasNextPage={page < pages} />
      <Pagination page={page} setPage={setPage} pages={pages} hasNextPage={page < pages} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

You might notice when we get to page 9, pages start disappearing before our eyes. That's because we haven't added any previous pages yet (only the next ones).

Adding the previous pages

We'll follow a similar pattern to add the previous pages, but instead of checking if the next page exists in pages, we want to check if the previous page exists because it is greater than 0.

page - 3 > 0
page - 2 > 0
page - 1 > 0

If we are on page 1 and the previous page is 0, we don't want to show it.

If we are on page 2 and the previous page is 1, we do want to show it.

If we are on page 3 then the previous page is 2 and the page before that is 1, so we want to show them both.

I'll also only show these previous pages if the pages property exists (because if it doesn't we only want to show the current page).

Again, we want to show up to 5 pages at a time, so I'll add up to 4 previous pages.

return (
  <div className="p-5 flex items-center justify-center text-gray-600">
    <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
    { pages && page - 4 > 0 && <Button onClick={() => setPage(page - 4)}>{page - 4}</Button> }
    { pages && page - 3 > 0 && <Button onClick={() => setPage(page - 3)}>{page - 3}</Button> }
    { pages && page - 2 > 0 && <Button onClick={() => setPage(page - 2)}>{page - 2}</Button> }
    { pages && page - 1 > 0 && <Button onClick={() => setPage(page - 1)}>{page - 1}</Button> }
    <Button active={true}>{page}</Button>
    { page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }
    { page + 2 <= pages && <Button onClick={() => setPage(page + 2)}>{page + 2}</Button> }
    { page + 3 <= pages && <Button onClick={() => setPage(page + 3)}>{page + 3}</Button> }
    { page + 4 <= pages && <Button onClick={() => setPage(page + 4)}>{page + 4}</Button> }
    <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
  </div>
);
<div id="app"></div>
// button component for the pagination
function Button({ active, children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded ${ active ? "text-blue-500 font-bold" : "bg-gray-50 enabled:hover:bg-gray-100 disabled:opacity-50" } ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage, pages, hasNextPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
      { pages && page - 4 > 0 && <Button onClick={() => setPage(page - 4)}>{page - 4}</Button> }
      { pages && page - 3 > 0 && <Button onClick={() => setPage(page - 3)}>{page - 3}</Button> }
      { pages && page - 2 > 0 && <Button onClick={() => setPage(page - 2)}>{page - 2}</Button> }
      { pages && page - 1 > 0 && <Button onClick={() => setPage(page - 1)}>{page - 1}</Button> }
      <Button active={true}>{page}</Button>
      { page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }
      { page + 2 <= pages && <Button onClick={() => setPage(page + 2)}>{page + 2}</Button> }
      { page + 3 <= pages && <Button onClick={() => setPage(page + 3)}>{page + 3}</Button> }
      { page + 4 <= pages && <Button onClick={() => setPage(page + 4)}>{page + 4}</Button> }
      <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  const pages = 12; // dummy data for example
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} hasNextPage={page < pages} />
      <Pagination page={page} setPage={setPage} pages={pages} hasNextPage={page < pages} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

That's pretty cool, but you might notice a problem. The Pagination always shows at least 5 pages, but sometimes it shows 9!

Always showing the same number of pages

This one is a little tricky. We want to always show the same number of pages, but at the start, we need to show the next 4 pages, and as our current page number moves up, it should stick in position 3, and show the two next pages, and the two previous pages.

That is - until we get to the end of our available pages, at which point we need to show the 4 previous pages.

🤯

Ok, we can do this. Deep breaths everybody!

We only want to show page 5 if our page is number 1. Once we get to page 2, a previous page (page 1) will show and we will need to remove one of the next pages to make room for it.

Instead of page + 4 <= pages our condition needs to be page + 4 <= pages && page < 2.

And we only want to show page 4 if our page is number 1 or number 2. By page 3, we will have two previous pages (pages 1 and 2) moving our current page to the middle of the page list.

Instead of page + 3 <= pages our condition needs to be page + 3 <= pages && page < 3.

That's our next pages sorted, but what about the previous ones?

This one is hard to explain so bear with me!

We only want to show our 3rd and 4th previous pages (page - 4 && page - 3) when our current page gets to the end of our available pages. When we reach the end, our next pages will disappear, so we need to show more previous pages.

We want to show the 3rd previous page, if we are within one page of the end (e.g. if there are 12 pages and we are on page 11) - because we will only have one next page to show so we need to replace it with a previous page.

Instead of pages && page - 3 > 0 our condition needs to be pages - page < 2 && page - 3 > 0.

And we only want to show the 4th previous page if we are on the last page.

Instead of pages && page - 4 > 0 our condition needs to be pages - page < 1 && page - 4 > 0.

Here's the finished code:

// pagination component
function Pagination({ page, setPage, pages, hasNextPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
      { pages - page < 1 && page - 4 > 0 && <Button onClick={() => setPage(page - 4)}>{page - 4}</Button> }
      { pages - page < 2 && page - 3 > 0 && <Button onClick={() => setPage(page - 3)}>{page - 3}</Button> }
      { pages && page - 2 > 0 && <Button onClick={() => setPage(page - 2)}>{page - 2}</Button> }
      { pages && page - 1 > 0 && <Button onClick={() => setPage(page - 1)}>{page - 1}</Button> }
      <Button active={true}>{page}</Button>
      { page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }
      { page + 2 <= pages && <Button onClick={() => setPage(page + 2)}>{page + 2}</Button> }
      { page + 3 <= pages && page < 3 && <Button onClick={() => setPage(page + 3)}>{page + 3}</Button> }
      { page + 4 <= pages && page < 2 && <Button onClick={() => setPage(page + 4)}>{page + 4}</Button> }
      <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
    </div>
  );
};

And here's the Pagination component working with (bottom) and without (top) passing it the pages prop.

<div id="app"></div>
// button component for the pagination
function Button({ active, children, ...props }) {
  return (
    <button className={`inline-block py-3 mx-px text-center leading-none rounded ${ active ? "text-blue-500 font-bold" : "bg-gray-50 enabled:hover:bg-gray-100 disabled:opacity-50" } ${ typeof children === "number" ? "w-12 px-1" : "px-4"}`} {...props}>{children}</button>
  );
};


// pagination component
function Pagination({ page, setPage, pages, hasNextPage }) {
  
  // handler for the next button
  function handleNext() {
    setPage(page+1);
  };
  
  // handler for the previous button
  function handlePrevious() {
    setPage(page-1);
  };
  
  return (
    <div className="p-5 flex items-center justify-center text-gray-600">
      <Button onClick={handlePrevious} disabled={page <= 1}>Previous</Button>
      { pages - page < 1 && page - 4 > 0 && <Button onClick={() => setPage(page - 4)}>{page - 4}</Button> }
      { pages - page < 2 && page - 3 > 0 && <Button onClick={() => setPage(page - 3)}>{page - 3}</Button> }
      { pages && page - 2 > 0 && <Button onClick={() => setPage(page - 2)}>{page - 2}</Button> }
      { pages && page - 1 > 0 && <Button onClick={() => setPage(page - 1)}>{page - 1}</Button> }
      <Button active={true}>{page}</Button>
      { page + 1 <= pages && <Button onClick={() => setPage(page + 1)}>{page + 1}</Button> }
      { page + 2 <= pages && <Button onClick={() => setPage(page + 2)}>{page + 2}</Button> }
      { page + 3 <= pages && page < 3 && <Button onClick={() => setPage(page + 3)}>{page + 3}</Button> }
      { page + 4 <= pages && page < 2 && <Button onClick={() => setPage(page + 4)}>{page + 4}</Button> }
      <Button onClick={handleNext} disabled={!hasNextPage}>Next</Button>
    </div>
  );
};


// for displaying the pagination component
function App() {
  // state
  const [page, setPage] = React.useState(1);
  const pages = 12; // dummy data for example
  
  // do something when the page changes
  React.useEffect(() => {
    // go get another page of results
  }, [page]);
  
  return (
    <div className="flex flex-col w-full min-h-screen items-center justify-center p-5">
      <Pagination page={page} setPage={setPage} hasNextPage={page < pages} />
      <Pagination page={page} setPage={setPage} pages={pages} hasNextPage={page < pages} />
    </div>
  );
};


// ========================================
// render the react app
ReactDOM.render(
  <App />,
  document.getElementById('app')
);

If you are using React Query for data fetching, here's how I set up React Query for my paginated API requests.