React Pagination Example: Best Practices for State Management and Component Structure

A production-ready React pagination component leverages useState for page tracking, useMemo for data slicing, and useCallback for stable navigation handlers, while isolating presentation logic in pure child components.

Implementing pagination in React requires careful orchestration of state to prevent performance bottlenecks and ensure predictable behavior. This react pagination example demonstrates patterns derived directly from the facebook/react source code, showing how core hooks work together to create scalable, testable components that separate container logic from presentational concerns.

Core Hooks for Pagination State

The React source code in packages/react/src/ReactHooks.js provides the foundation for all state management patterns in this example. Understanding these hook implementations explains why specific patterns yield better performance for pagination UIs.

Tracking Current Page with useState

The current page number represents simple scalar state that updates directly in response to user interaction. According to the React source, useState resolves through the dispatcher at lines 66-71 in packages/react/src/ReactHooks.js:

// From packages/react/src/ReactHooks.js#L66-L71
export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

For pagination, initialize useState with 1 and update it via navigation handlers to ensure predictable state transitions.

Memoizing Data Slices with useMemo

Slicing large arrays on every render creates O(n) performance bottlenecks. The useMemo hook, implemented at lines 143-148 in packages/react/src/ReactHooks.js, caches expensive computations:

// From packages/react/src/ReactHooks.js#L143-L148
export function useMemo<T>(
  create: () => T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useMemo(create, deps);
}

Apply useMemo to calculate the current page's data slice, depending on the source array, current page, and page size to prevent unnecessary array operations.

Stabilizing Callbacks with useCallback

Navigation handlers passed to child components must maintain referential equality to prevent unnecessary re-renders. The useCallback implementation at lines 135-140 in packages/react/src/ReactHooks.js serves this purpose:

// From packages/react/src/ReactHooks.js#L135-L140
export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

Wrap goToPage, nextPage, and previousPage functions with useCallback, including totalPages in the dependency array to ensure stable references for memoized child components.

Handling Side Effects with useEffect

Synchronizing pagination state with external systems—such as browser URLs or server requests—requires useEffect. The implementation at lines 87-101 in packages/react/src/ReactHooks.js handles these imperative operations:

// From packages/react/src/ReactHooks.js#L87-L101
export function useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}

Use this hook to update window.history when the page changes or to trigger data fetches in server-side pagination scenarios, always providing cleanup functions to prevent memory leaks.

Client-Side Pagination Implementation

For datasets that fit in memory, client-side pagination provides instant navigation without network latency. This implementation demonstrates the container-presentational pattern with strict separation of concerns.

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

// ---- Pagination UI (pure) -------------------------------------------------
type PagerProps = {
  current: number;
  totalPages: number;
  onPageChange: (page: number) => void;
};

function Pager({current, totalPages, onPageChange}: PagerProps) {
  const pages = [...Array(totalPages)].map((_, i) => i + 1);

  return (
    <nav aria-label="Pagination">
      <button
        onClick={() => onPageChange(current - 1)}
        disabled={current === 1}
      >
        Prev
      </button>

      {pages.map(p => (
        <button
          key={p}
          onClick={() => onPageChange(p)}
          aria-current={p === current ? 'page' : undefined}
          style={{fontWeight: p === current ? 'bold' : 'normal'}}
        >
          {p}
        </button>
      ))}

      <button
        onClick={() => onPageChange(current + 1)}
        disabled={current === totalPages}
      >
        Next
      </button>
    </nav>
  );
}

// ---- Pagination container -------------------------------------------------
type Props<T> = {
  items: T[];
  pageSize?: number;
  renderItem: (item: T) => React.ReactNode;
};

export function PaginatedList<T>({
  items,
  pageSize = 10,
  renderItem,
}: Props<T>) {
  // 1️⃣ current page state (useState) — see ReactHooks.js source
  const [currentPage, setCurrentPage] = useState(1);

  // 2️⃣ total page count (derived)
  const totalPages = Math.ceil(items.length / pageSize);

  // 3️⃣ memoised slice for the current page (useMemo)
  const currentItems = useMemo(() => {
    const start = (currentPage - 1) * pageSize;
    return items.slice(start, start + pageSize);
  }, [items, currentPage, pageSize]);

  // 4️⃣ stable navigation handler (useCallback)
  const goToPage = useCallback(
    (page: number) => {
      const safePage = Math.max(1, Math.min(page, totalPages));
      setCurrentPage(safePage);
    },
    [totalPages],
  );

  return (
    <div>
      <ul>
        {currentItems.map((item, idx) => (
          <li key={idx}>{renderItem(item)}</li>
        ))}
      </ul>

      <Pager
        current={currentPage}
        totalPages={totalPages}
        onPageChange={goToPage}
      />
    </div>
  );
}

This example demonstrates how useState, useMemo, and useCallback work together to create a performant pagination component. The Pager UI remains pure and stateless, receiving all data and callbacks through props, while PaginatedList encapsulates all state management logic.

Server-Side Pagination with URL Synchronization

For large datasets, server-side pagination reduces initial load time and memory pressure. This implementation adds URL synchronization using useSearchParams from React Router, allowing users to bookmark and share specific pages.

import React, {useState, useEffect, useMemo, useCallback} from 'react';
import {useSearchParams} from 'react-router-dom';

// Mock API – in a real app this would hit a back‑end
async function fetchPage(page: number, size: number) {
  const response = await fetch(
    `/api/items?page=${page}&size=${size}`,
  );
  return response.json(); // {items: [], totalCount: number}
}

export function ServerPaginatedList({pageSize = 20, renderItem}) {
  const [searchParams, setSearchParams] = useSearchParams();
  const initialPage = Number(searchParams.get('page')) || 1;

  const [currentPage, setCurrentPage] = useState(initialPage);
  const [data, setData] = useState({items: [], totalCount: 0});
  const totalPages = Math.ceil(data.totalCount / pageSize);

  // Side‑effect: load data when page changes (useEffect)
  useEffect(() => {
    let cancelled = false;
    fetchPage(currentPage, pageSize).then(res => {
      if (!cancelled) setData(res);
    });
    return () => {
      cancelled = true;
    };
  }, [currentPage, pageSize]);

  // Keep URL in sync
  useEffect(() => {
    setSearchParams({page: String(currentPage)});
  }, [currentPage, setSearchParams]);

  const goToPage = useCallback(
    (page: number) => setCurrentPage(Math.max(1, Math.min(page, totalPages))),
    [totalPages],
  );

  return (
    <>
      <ul>
        {data.items.map(item => (
          <li key={item.id}>{renderItem(item)}</li>
        ))}
      </ul>

      <Pager
        current={currentPage}
        totalPages={totalPages}
        onPageChange={goToPage}
      />
    </>
  );
}

This server-side implementation leverages useEffect for data fetching, as implemented in packages/react/src/ReactHooks.js lines 87-101. The cleanup function prevents memory leaks by cancelling stale requests when the component unmounts or the page changes rapidly.

Component Architecture Best Practices

Separating stateful logic from presentational components creates a maintainable pagination system. The container-presentational pattern, combined with React's memoization APIs, ensures optimal performance even with large datasets.

State Management Strategy

  • Current page number: Manage with useState in the container component, initialized from URL parameters if server-side.
  • Page size: Store as a constant or prop, rarely changing after mount.
  • Derived data slice: Compute with useMemo to avoid re-slicing arrays on every render.
  • Navigation callbacks: Wrap with useCallback to maintain stable references for child components.
  • Side effects: Handle data fetching and URL updates in useEffect with proper cleanup functions.

Performance Optimizations

  1. Memoize expensive calculations: The useMemo implementation in packages/react/src/ReactHooks.js (lines 143-148) caches the array slice operation, preventing O(n) work on every render.

  2. Stabilize callbacks: useCallback (lines 135-140 in packages/react/src/ReactHooks.js) ensures that navigation handlers maintain referential equality, allowing React.memo to effectively prevent re-renders of pure UI components.

  3. Abortable requests: When using useEffect for server-side pagination, always implement a cancellation mechanism using the cleanup function to prevent race conditions and memory leaks.

Summary

  • Use useState in packages/react/src/ReactHooks.js (lines 66-71) to track the current page as simple scalar state.
  • Apply useMemo (lines 143-148) to derive the current page's data slice, preventing expensive array operations on every render.
  • Wrap handlers with useCallback (lines 135-140) to maintain stable references for memoized child components.
  • Leverage useEffect (lines 87-101) for side effects like data fetching and URL synchronization, always providing cleanup functions.
  • Separate concerns by isolating state logic in container components and rendering logic in pure presentational components.

Frequently Asked Questions

How do I prevent unnecessary re-renders in a React pagination component?

Use useCallback to memoize navigation functions and useMemo for derived data calculations. According to the React source in packages/react/src/ReactHooks.js (lines 135-140), useCallback ensures that child components receiving these functions as props won't re-render unless the dependencies actually change. Additionally, split your component into a stateful container and a pure presentational component that can be wrapped in React.memo.

Should I use useState or useReducer for pagination state?

For standard pagination with next/previous buttons and direct page selection, useState is sufficient and simpler. The implementation in packages/react/src/ReactHooks.js (lines 66-71) shows that useState is optimized for scalar values that update directly. However, if your pagination includes complex state machines with loading states, error handling, and jump-to-first/last logic, useReducer (lines 73-80) provides better structure for related state transitions.

How do I synchronize pagination state with the URL?

Use useSearchParams from React Router to read the initial page from the URL, and useEffect to update the URL when the page changes. Initialize your useState with the value from search parameters, then use an effect that calls setSearchParams whenever currentPage updates. This approach, leveraging the useEffect implementation in packages/react/src/ReactHooks.js (lines 87-101), ensures the browser's back button works correctly and allows users to bookmark specific pages.

What is the best way to handle server-side pagination in React?

Implement an abortable fetch pattern using useEffect with a cleanup function. Store the current page in useState, then use useEffect to fetch data whenever the page or page size changes. Return a cleanup function that sets a cancellation flag to prevent state updates on unmounted components or stale requests. Combine this with useMemo to calculate totalPages from the API response, and useCallback for the goToPage handler to maintain stable references for child components.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →