≡ Menu

How to Build a Pagination Feature In React 🚀

Managing large datasets in your React application can slow down performance and overwhelm your users.

The solution?

Pagination!

Instead of rendering a massive list all at once, you show a manageable chunk, improving both speed and user experience.

In this tutorial, we’ll walk through a complete, clean React component that fetches data using the native Fetch API (no bulky third-party libraries needed!) and implements robust pagination logic. We’ll use the JSONPlaceholder API for our dummy data.


1. Setting Up the State and Imports

We’ll start with the necessary imports from React and define the initial state for our data, pagination settings, and current page.

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

const App = () => {
  // todos: Holds the full array of data fetched from the API
  const [todos, setTodos] = useState([]);
  
  // todosPerPage: Determines how many items show on one page
  const [todosPerPage] = useState(10); 
  
  // currentPage: Tracks which page the user is currently viewing
  const [currentPage, setCurrentPage] = useState(1);

  // ... rest of the component

2. Fetching Data with useEffect and Fetch API

We use the useEffect hook to fetch the data only once when the component mounts. Notice how we’re using the native fetch API, which is a modern, built-in alternative to older libraries like Axios.

💡 Fetch API Tip: Unlike Axios, the native fetch returns a raw response object, so you must explicitly call .json() on the response to parse the body into a JavaScript object.

  // Fetch data only once on mount
  useEffect(() => {
    // ⬇️ Using the native Fetch API 
    fetch("https://jsonplaceholder.typicode.com/todos")
      .then((res) => res.json()) // 1. Convert the raw response to JSON
      .then((data) => setTodos(data)) // 2. Use the JSON data to update state
      .catch((error) => console.error("Error fetching todos:", error)); // 3. Error handling is crucial!
  }, []); // The empty array dependency ensures it runs only once.

3. Implementing the Core Pagination Logic

The magic of pagination happens here. We need three key pieces of information to determine what to show:

  1. Total number of pages: Found by dividing the total items by items per page and rounding up using Math.ceil().
  2. Indices for slicing: We calculate the starting (indexOfFirstTodo) and ending (indexOfLastTodo) indices for the array slice based on the current page.
  3. The visible data: We use the array’s .slice() method to extract only the items for the current page.
// --- Pagination Logic ---
  // Calculate total pages needed
  const numOfTotalPages = Math.ceil(todos.length / todosPerPage);
  
  // Create an array of page numbers: [1, 2, 3, ..., numOfTotalPages]
  const pages = [...Array(numOfTotalPages + 1).keys()].slice(1);

  // Calculate the index range for the current page's slice
  const indexOfLastTodo = currentPage * todosPerPage;
  const indexOfFirstTodo = indexOfLastTodo - todosPerPage;

  // Slice the full todos array to get only the items for the current view
  const visibleTodos = todos.slice(indexOfFirstTodo, indexOfLastTodo);

  // ... rest of the component

4. Navigation Handlers

We’ll create simple functions to handle clicking the “Prev” and “Next” buttons, ensuring the page number doesn’t go below 1 or above the total number of pages.

  // --- Handlers ---
  const prevPageHandler = () => {
    // Only allow navigating back if not on the first page
    if (currentPage !== 1) setCurrentPage(currentPage - 1);
  };

  const nextPageHandler = () => {
    // Only allow navigating forward if not on the last page
    if (currentPage !== numOfTotalPages) setCurrentPage(currentPage + 1);
  };

5. Rendering the UI and Controls

Finally, we render the data and the pagination controls. We use the visibleTodos array for the main list and the pages array for generating the clickable page numbers.

  return (
    <div>
      <h2>Todo List (Page {currentPage})</h2>
      
      {/* Display a loading message while todos are empty */}
      {todos.length === 0 ? (
        <p>Loading todos...</p>
      ) : (
        /* MAP OVER THE VISIBLE TODOS ONLY */
        visibleTodos.map((todo) =>
          <p key={todo.id}>
            **{todo.id}**: {todo.title} 
            {/* Optional: Show status based on 'completed' property */}
          </p>
        )
      )}

      <hr />

      {/* PAGINATION CONTROLS */}
      <span 
        onClick={prevPageHandler}
        // Disable 'Prev' button visually and functionally on page 1
        style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
      >
        Prev
      </span>
      
      <p>
        {pages.map((page) => (
          <span
            key={page}
            onClick={() => setCurrentPage(page)}
            className={`${currentPage === page ? "active" : ""}`} 
            style={{ 
              margin: '0 5px', 
              fontWeight: currentPage === page ? 'bold' : 'normal',
              cursor: 'pointer'
            }}
          >
            {`${page} | `}
          </span>
        ))}
      </p>

      <span 
        onClick={nextPageHandler}
        // Disable 'Next' button visually and functionally on the last page
        style={{ cursor: currentPage === numOfTotalPages ? 'not-allowed' : 'pointer' }}
      >
        Next
      </span>
      
      {/* A simple style for the active class for demonstration */}
      <style>{`
        .active {
          color: blue;
          text-decoration: underline;
        }
      `}</style>
    </div>
  );
};

export default App;

Full Code Summary

Here is the complete code for your App.js component:

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

const App = () => {
  const [todos, setTodos] = useState([]);
  const [todosPerPage] = useState(10); 
  const [currentPage, setCurrentPage] = useState(1);

  // Fetch data only once on mount
  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/todos")
      .then((res) => res.json()) 
      .then((data) => setTodos(data)) 
      .catch((error) => console.error("Error fetching todos:", error)); 
  }, []);

  // --- Pagination Logic ---
  const numOfTotalPages = Math.ceil(todos.length / todosPerPage);
  const pages = [...Array(numOfTotalPages + 1).keys()].slice(1);

  const indexOfLastTodo = currentPage * todosPerPage;
  const indexOfFirstTodo = indexOfLastTodo - todosPerPage;

  const visibleTodos = todos.slice(indexOfFirstTodo, indexOfLastTodo);

  // --- Handlers ---
  const prevPageHandler = () => {
    if (currentPage !== 1) setCurrentPage(currentPage - 1);
  };

  const nextPageHandler = () => {
    if (currentPage !== numOfTotalPages) setCurrentPage(currentPage + 1);
  };

  return (
    <div>
      <h2>Todo List (Page {currentPage})</h2>
      
      {/* Display a loading message while todos are empty */}
      {todos.length === 0 ? (
        <p>Loading todos...</p>
      ) : (
        /* MAP OVER THE VISIBLE TODOS ONLY */
        visibleTodos.map((todo) =>
          <p key={todo.id}>**{todo.id}**: {todo.title}</p>
        )
      )}

      <hr />

      {/* PAGINATION CONTROLS */}
      <span 
        onClick={prevPageHandler}
        style={{ cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}
      >
        Prev
      </span>
      
      <p>
        {pages.map((page) => (
          <span
            key={page}
            onClick={() => setCurrentPage(page)}
            className={`${currentPage === page ? "active" : ""}`} 
            style={{ 
              margin: '0 5px', 
              fontWeight: currentPage === page ? 'bold' : 'normal',
              cursor: 'pointer'
            }}
          >
            {`${page} | `}
          </span>
        ))}
      </p>

      <span 
        onClick={nextPageHandler}
        style={{ cursor: currentPage === numOfTotalPages ? 'not-allowed' : 'pointer' }}
      >
        Next
      </span>
      
      {/* A simple style for the active class for demonstration */}
      <style>{`
        .active {
          color: blue;
          text-decoration: underline;
        }
      `}</style>
    </div>
  );
};

export default App;

This setup provides a clean, efficient, and library-free way to handle both data fetching and client-side pagination in your React applications! Happy coding! 💻

{ 0 comments }

As an e-commerce developer, you want a buttery-smooth shopping experience. Nothing is worse than a user adding an item in one tab only to open another and see the cart icon still showing “0.” It breaks the user experience and can lead to confusion.

The solution? Inter-tab communication!

We can leverage the BroadcastChannel API and modern React Hooks to instantly synchronize the cart across every open browser tab for your site, no server polling or messy localStorage event hacks required.

Here’s a breakdown of the React code that makes this real-time sync happen.


 

The Synchronized Cart Component: Code Explained

 

This component, CartComponent, manages the cart state and acts as both a broadcaster (when adding an item) and a listener (when an item is added by another tab).

1. The Setup: State and Channel Initialization

 

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

// Define the channel name
const CART_CHANNEL_NAME = "cart_channel";

const CartComponent = () => {
  // 1. Initialize cart state
  const [cart, setCart] = useState([]);

  // 2. Memoize the BroadcastChannel instance
  const cartChannel = useMemo(() => new BroadcastChannel(CART_CHANNEL_NAME), []);
// ...
  • useState: Initializes the component’s local cart state (cart).
  • CART_CHANNEL_NAME: This constant defines the unique radio frequency ("cart_channel") that all open tabs must tune into to communicate.
  • useMemo: This is crucial. We use useMemo to ensure the BroadcastChannel instance (cartChannel) is created only once when the component first renders. If we created a new channel on every render, the communication would fail, or be highly inefficient.

 

2. The Listener: Receiving Updates (useEffect)

 

  // --- Effect for LISTENING (Updates from other tabs) ---
  useEffect(() => {
    // Set the listener for messages from *other* tabs
    cartChannel.onmessage = (event) => {
      // The event.data contains the new cart array from the tab that just updated it
      setCart(event.data);
      console.log('Cart updated from another tab:', event.data);
    };

    // Cleanup function to close the channel when the component unmounts
    return () => {
      cartChannel.close();
      console.log('BroadcastChannel closed.');
    };
  }, [cartChannel]);
  • cartChannel.onmessage: This is the dedicated event handler that fires only when a message is sent from another tab on the same channel.
  • setCart(event.data): When a message arrives, we take the payload (event.data)—which is the complete updated cart array—and use it to update the local React state. This instantly forces the current tab to re-render with the new cart count and items.
  • Cleanup Function (return () => cartChannel.close()): Best practice dictates closing the channel when the component unmounts (i.e., when the user closes the tab or navigates away). This frees up browser resources.

 

3. The Broadcaster: Sending Updates (addItemToCart)

 

  // --- Function for UPDATING (Sends update to other tabs) ---
  const addItemToCart = (item) => {
    // 1. Calculate the new state
    const updatedCart = [...cart, item];

    // 2. Update the local state (for the current tab's display)
    setCart(updatedCart);
    console.log('Local cart updated:', updatedCart);

    // 3. Post the message to other tabs
    cartChannel.postMessage(updatedCart);
  };
// ...
  1. Local Update: First, the function calculates the updatedCart and immediately calls setCart(updatedCart). This ensures the current tab’s display updates instantly.
  2. Broadcast: Next, it calls cartChannel.postMessage(updatedCart). This sends the entire, up-to-date cart array out to the channel. All other tabs with this component open will pick up this message via their onmessage listener (Section 2) and update their UI.

 

Why This Approach Works So Well

 

  • Efficiency: The BroadcastChannel is a direct browser API, making it extremely fast. You’re not relying on network requests or constant server polling.
  • Simplicity: It abstracts away the complexity of handling multiple windows. You just send data and receive it.
  • Scalability: Since communication is direct between browser tabs (client-side), it doesn’t add any extra load to your server.

By combining the powerful BroadcastChannel API with React Hooks like useState, useEffect, and useMemo, you can build a truly synchronous and high-quality e-commerce experience that feels modern and seamless!

Useful links below:

Let me & my team build you a money making website/blog for your business https://bit.ly/tnrwebsite_service

Get Bluehost hosting for as little as $1.99/month (save 75%)…https://bit.ly/3C1fZd2

Join my Patreon for one-on-one coaching and help with your coding…https://www.patreon.com/c/TyronneRatcliff

Buy me a coffee ☕️https://buymeacoffee.com/tyronneratcliff

{ 0 comments }