Menu

Mastering React MUI Pagination: Building Effective Page Navigation for Blogs and Listings

Pagination is a critical UI pattern for managing large datasets in web applications. When building a blog, product catalog, or any content-heavy interface, implementing an effective pagination system improves user experience by breaking content into manageable chunks. Material-UI (MUI) offers a robust Pagination component that balances functionality with Material Design aesthetics.

In this comprehensive guide, I'll walk you through implementing MUI's Pagination component for React applications, covering everything from basic setup to advanced customizations and real-world integration patterns.

What You'll Learn

By the end of this article, you'll be able to:

  • Implement basic MUI Pagination in React applications
  • Understand and utilize all Pagination component props and variants
  • Create controlled and uncontrolled pagination components
  • Customize pagination appearance with MUI theming and styling options
  • Integrate pagination with data fetching from APIs
  • Handle edge cases and accessibility requirements
  • Optimize pagination performance for large datasets

Understanding MUI's Pagination Component

The Pagination component in MUI provides a user-friendly way to navigate through multiple pages of content. It's part of MUI's core library and follows Material Design principles while offering extensive customization options.

Core Features and Capabilities

MUI's Pagination component offers several key features that make it powerful and flexible:

  1. Visual Variants: Standard, outlined, and rounded styles to match your design system
  2. Size Options: Small, medium, and large sizes for different UI contexts
  3. Customizable Range: Control how many page numbers are displayed
  4. Navigation Controls: Optional previous/next buttons and first/last page buttons
  5. Accessibility Support: Built-in keyboard navigation and screen reader compatibility
  6. Theming Integration: Seamless customization through MUI's theming system

Basic Component API

Before diving into implementation, let's understand the component's API. The Pagination component accepts numerous props that control its behavior and appearance.

PropTypeDefaultDescription
countnumber1Number of pages
pagenumber-Current page (for controlled component)
defaultPagenumber1Default page (for uncontrolled component)
onChangefunction-Callback fired when page changes
color'primary' | 'secondary' | 'standard''standard'Color of the component
disabledbooleanfalseIf true, the component is disabled
hideNextButtonbooleanfalseIf true, hide the next-page button
hidePrevButtonbooleanfalseIf true, hide the previous-page button
showFirstButtonbooleanfalseIf true, show the first-page button
showLastButtonbooleanfalseIf true, show the last-page button
size'small' | 'medium' | 'large''medium'The size of the component
siblingCountnumber1Number of siblings displayed on each side of current page
shape'circular' | 'rounded''circular'The shape of the pagination items
variant'text' | 'outlined''text'The variant to use
boundaryCountnumber1Number of pages at the beginning and end

Getting Started with MUI Pagination

Let's start by setting up a basic pagination component for a blog or listing page. We'll first need to install the required packages.

Installation

If you haven't already set up a React project with MUI, you'll need to install the necessary dependencies:

npm install @mui/material @emotion/react @emotion/styled

If you're using yarn:

yarn add @mui/material @emotion/react @emotion/styled

Basic Implementation

Let's create a simple pagination component for a blog listing:

import React, { useState } from 'react';
import { Box, Pagination, Typography } from '@mui/material';

const BlogPagination = () => {
const [page, setPage] = useState(1);
const totalPages = 10; // This would typically come from your API or data source

const handleChange = (event, value) => {
setPage(value);
};

return (
<Box sx={{ my: 4, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography variant="body1" sx={{ mb: 2 }}>
Page {page} of {totalPages}
</Typography>
<Pagination 
        count={totalPages} 
        page={page} 
        onChange={handleChange} 
        color="primary" 
      />
</Box>
);
};

export default BlogPagination;

In this basic example:

  1. We create a controlled component using React's useState hook to track the current page
  2. We set a total number of pages (in a real application, this would come from your API or data source)
  3. We implement a change handler to update the page state when the user clicks on a different page
  4. We display the current page information above the pagination control

Controlled vs. Uncontrolled Pagination

MUI's Pagination component can be used in both controlled and uncontrolled modes. Let's examine both approaches:

Controlled Pagination

The example above demonstrates a controlled component where we explicitly manage the state:

import React, { useState } from 'react';
import { Pagination } from '@mui/material';

const ControlledPagination = () => {
const [page, setPage] = useState(1);

const handleChange = (event, value) => {
setPage(value);
// Here you would typically fetch data for the new page
console.log(`Loading data for page ${value}`);
};

return (
<Pagination 
      count={10} 
      page={page} 
      onChange={handleChange} 
    />
);
};

export default ControlledPagination;

With a controlled component, you have complete control over the page state, making it easier to sync with other parts of your application like data fetching.

Uncontrolled Pagination

For simpler use cases, you can use an uncontrolled component by specifying a defaultPage instead of managing state:

import React from 'react';
import { Pagination } from '@mui/material';

const UncontrolledPagination = () => {
const handleChange = (event, value) => {
// Handle page change events
console.log(`Page changed to ${value}`);
};

return (
<Pagination 
      count={10} 
      defaultPage={1} 
      onChange={handleChange} 
    />
);
};

export default UncontrolledPagination;

Uncontrolled components are simpler to implement but offer less control over the component's behavior.

Styling and Customizing MUI Pagination

MUI's Pagination component offers extensive customization options to match your application's design requirements.

Styling with the sx Prop

The sx prop is the most direct way to style MUI components:

import React, { useState } from 'react';
import { Pagination, Box } from '@mui/material';

const CustomStyledPagination = () => {
const [page, setPage] = useState(1);

return (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination
count={10}
page={page}
onChange={(e, p) => setPage(p)}
sx={{
          '& .MuiPaginationItem-root': {
            color: '#555',
            fontWeight: 'medium',
          },
          '& .MuiPaginationItem-page.Mui-selected': {
            backgroundColor: '#2d5986',
            color: 'white',
            '&:hover': {
              backgroundColor: '#1a365d',
            },
          },
          '& .MuiPaginationItem-page:hover': {
            backgroundColor: 'rgba(45, 89, 134, 0.1)',
          },
        }}
/>
</Box>
);
};

export default CustomStyledPagination;

This example customizes the pagination items' colors and hover states using the sx prop, which leverages MUI's styling system.

Using Variants and Sizes

MUI's Pagination component offers different variants and sizes to suit various design needs:

import React from 'react';
import { Box, Pagination, Stack, Typography } from '@mui/material';

const PaginationVariants = () => {
return (
<Stack spacing={4}>
<Box>
<Typography variant="subtitle1" gutterBottom>
Standard Variant (Default)
</Typography>
<Pagination count={10} color="primary" />
</Box>

      <Box>
        <Typography variant="subtitle1" gutterBottom>
          Outlined Variant
        </Typography>
        <Pagination count={10} variant="outlined" color="primary" />
      </Box>

      <Box>
        <Typography variant="subtitle1" gutterBottom>
          Rounded Shape
        </Typography>
        <Pagination count={10} shape="rounded" color="primary" />
      </Box>

      <Box>
        <Typography variant="subtitle1" gutterBottom>
          Small Size
        </Typography>
        <Pagination count={10} size="small" color="primary" />
      </Box>

      <Box>
        <Typography variant="subtitle1" gutterBottom>
          Large Size
        </Typography>
        <Pagination count={10} size="large" color="primary" />
      </Box>
    </Stack>

);
};

export default PaginationVariants;

This example showcases different pagination variants and sizes, which can be mixed and matched according to your design requirements.

Theme Customization

For application-wide consistency, you can customize the Pagination component through MUI's theming system:

import React from 'react';
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { Box, Pagination } from '@mui/material';

const theme = createTheme({
components: {
MuiPagination: {
styleOverrides: {
root: {
'& .MuiPaginationItem-root': {
fontFamily: 'Roboto Mono, monospace',
},
},
},
},
MuiPaginationItem: {
styleOverrides: {
root: {
'&.Mui-selected': {
backgroundColor: '#673ab7',
color: '#ffffff',
'&:hover': {
backgroundColor: '#5e35b1',
},
},
},
},
},
},
});

const ThemedPagination = () => {
return (
<ThemeProvider theme={theme}>
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<Pagination count={10} color="primary" />
</Box>
</ThemeProvider>
);
};

export default ThemedPagination;

Theme customization is powerful for maintaining design consistency across your entire application.

Real-World Implementation: Blog Pagination with API Integration

Now let's build a more complete example that integrates pagination with API data fetching for a blog listing page.

Setting Up the Blog Listing Component

First, we'll create a blog listing component that fetches and displays posts with pagination:

import React, { useState, useEffect } from 'react';
import { 
  Box, 
  Card, 
  CardContent, 
  Typography, 
  Pagination, 
  Stack,
  CircularProgress,
  Alert
} from '@mui/material';

const POSTS_PER_PAGE = 5;

const BlogListing = () => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);

useEffect(() => {
const fetchPosts = async () => {
setLoading(true);
try {
// In a real app, you'd fetch from your actual API
// This example uses JSONPlaceholder as a demo API
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${POSTS_PER_PAGE}`
);

        // Get total count from headers
        const totalCount = parseInt(response.headers.get('x-total-count') || '0', 10);
        setTotalPages(Math.ceil(totalCount / POSTS_PER_PAGE));

        if (!response.ok) {
          throw new Error('Failed to fetch posts');
        }

        const data = await response.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();

}, [page]); // Re-fetch when page changes

const handlePageChange = (event, newPage) => {
setPage(newPage);
// Scroll to top when page changes
window.scrollTo({ top: 0, behavior: 'smooth' });
};

return (
<Box sx={{ maxWidth: 800, mx: 'auto', py: 4, px: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
Blog Posts
</Typography>

      {loading ? (
        <Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
          <CircularProgress />
        </Box>
      ) : error ? (
        <Alert severity="error" sx={{ my: 2 }}>
          {error}
        </Alert>
      ) : (
        <>
          <Stack spacing={3} sx={{ my: 4 }}>
            {posts.map(post => (
              <Card key={post.id} sx={{ boxShadow: 2 }}>
                <CardContent>
                  <Typography variant="h6" component="h2" gutterBottom>
                    {post.title}
                  </Typography>
                  <Typography variant="body2" color="text.secondary">
                    {post.body}
                  </Typography>
                </CardContent>
              </Card>
            ))}
          </Stack>

          <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
            <Pagination
              count={totalPages}
              page={page}
              onChange={handlePageChange}
              color="primary"
              showFirstButton
              showLastButton
              size="large"
              sx={{
                '& .MuiPaginationItem-root': {
                  fontSize: '1rem'
                }
              }}
            />
          </Box>
        </>
      )}
    </Box>

);
};

export default BlogListing;

This example demonstrates:

  1. API Integration: Fetching paginated data from an API
  2. Loading States: Displaying a loading indicator while fetching data
  3. Error Handling: Showing an error message if the API request fails
  4. Dynamic Pagination: Calculating the total number of pages based on API response
  5. User Experience: Scrolling to the top when the page changes

Implementing URL-Based Pagination

For a better user experience, we should sync the pagination state with the URL, allowing users to bookmark or share specific pages:

import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { 
  Box, 
  Card, 
  CardContent, 
  Typography, 
  Pagination, 
  Stack,
  CircularProgress,
  Alert
} from '@mui/material';

const POSTS_PER_PAGE = 5;

const BlogListingWithUrlSync = () => {
const navigate = useNavigate();
const location = useLocation();

// Get page from URL query parameters or default to 1
const getInitialPage = () => {
const params = new URLSearchParams(location.search);
const pageParam = params.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
};

const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [page, setPage] = useState(getInitialPage);
const [totalPages, setTotalPages] = useState(0);

// Update URL when page changes
useEffect(() => {
const params = new URLSearchParams(location.search);

    if (page === 1) {
      params.delete('page');
    } else {
      params.set('page', page.toString());
    }

    const newSearch = params.toString();
    const query = newSearch ? `?${newSearch}` : '';

    navigate(`${location.pathname}${query}`, { replace: true });

}, [page, navigate, location.pathname, location.search]);

// Fetch posts based on current page
useEffect(() => {
const fetchPosts = async () => {
setLoading(true);
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${POSTS_PER_PAGE}`
);

        const totalCount = parseInt(response.headers.get('x-total-count') || '0', 10);
        setTotalPages(Math.ceil(totalCount / POSTS_PER_PAGE));

        if (!response.ok) {
          throw new Error('Failed to fetch posts');
        }

        const data = await response.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();

}, [page]);

const handlePageChange = (event, newPage) => {
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};

return (
<Box sx={{ maxWidth: 800, mx: 'auto', py: 4, px: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
Blog Posts
</Typography>

      {loading ? (
        <Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
          <CircularProgress />
        </Box>
      ) : error ? (
        <Alert severity="error" sx={{ my: 2 }}>
          {error}
        </Alert>
      ) : (
        <>
          <Stack spacing={3} sx={{ my: 4 }}>
            {posts.map(post => (
              <Card key={post.id} sx={{ boxShadow: 2 }}>
                <CardContent>
                  <Typography variant="h6" component="h2" gutterBottom>
                    {post.title}
                  </Typography>
                  <Typography variant="body2" color="text.secondary">
                    {post.body}
                  </Typography>
                </CardContent>
              </Card>
            ))}
          </Stack>

          <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
            <Pagination
              count={totalPages}
              page={page}
              onChange={handlePageChange}
              color="primary"
              showFirstButton
              showLastButton
              size="large"
              sx={{
                '& .MuiPaginationItem-root': {
                  fontSize: '1rem'
                }
              }}
            />
          </Box>
        </>
      )}
    </Box>

);
};

export default BlogListingWithUrlSync;

This enhanced example:

  1. Reads the initial page from the URL query parameter
  2. Updates the URL when the page changes
  3. Maintains browser history correctly
  4. Allows users to share or bookmark specific pages

Advanced Pagination Techniques

Let's explore some advanced techniques for pagination in real-world applications.

Implementing Server-Side Search with Pagination

Often, pagination needs to work alongside other filtering or search functionality:

import React, { useState, useEffect } from 'react';
import { 
  Box, 
  Card, 
  CardContent, 
  Typography, 
  Pagination, 
  Stack,
  TextField,
  Button,
  CircularProgress,
  Alert
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';

const POSTS_PER_PAGE = 5;

const SearchableBlogListing = () => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [searchInput, setSearchInput] = useState('');

useEffect(() => {
const fetchPosts = async () => {
setLoading(true);
try {
// In a real app, you'd use a proper search endpoint
// For this example, we're using title filtering with JSONPlaceholder
let url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${POSTS_PER_PAGE}`;

        if (searchQuery) {
          url += `&title_like=${encodeURIComponent(searchQuery)}`;
        }

        const response = await fetch(url);

        // Get total count from headers
        const totalCount = parseInt(response.headers.get('x-total-count') || '0', 10);
        setTotalPages(Math.ceil(totalCount / POSTS_PER_PAGE));

        if (!response.ok) {
          throw new Error('Failed to fetch posts');
        }

        const data = await response.json();
        setPosts(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();

}, [page, searchQuery]); // Re-fetch when page or search query changes

const handlePageChange = (event, newPage) => {
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};

const handleSearch = (e) => {
e.preventDefault();
setSearchQuery(searchInput);
setPage(1); // Reset to first page when searching
};

return (
<Box sx={{ maxWidth: 800, mx: 'auto', py: 4, px: 2 }}>
<Typography variant="h4" component="h1" gutterBottom>
Blog Posts
</Typography>

      {/* Search Form */}
      <Box
        component="form"
        onSubmit={handleSearch}
        sx={{
          display: 'flex',
          gap: 1,
          mb: 4
        }}
      >
        <TextField
          fullWidth
          label="Search posts"
          variant="outlined"
          value={searchInput}
          onChange={(e) => setSearchInput(e.target.value)}
          size="small"
        />
        <Button
          type="submit"
          variant="contained"
          startIcon={<SearchIcon />}
        >
          Search
        </Button>
      </Box>

      {loading ? (
        <Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
          <CircularProgress />
        </Box>
      ) : error ? (
        <Alert severity="error" sx={{ my: 2 }}>
          {error}
        </Alert>
      ) : (
        <>
          {posts.length === 0 ? (
            <Alert severity="info" sx={{ my: 2 }}>
              No posts found matching your search criteria.
            </Alert>
          ) : (
            <Stack spacing={3} sx={{ my: 4 }}>
              {posts.map(post => (
                <Card key={post.id} sx={{ boxShadow: 2 }}>
                  <CardContent>
                    <Typography variant="h6" component="h2" gutterBottom>
                      {post.title}
                    </Typography>
                    <Typography variant="body2" color="text.secondary">
                      {post.body}
                    </Typography>
                  </CardContent>
                </Card>
              ))}
            </Stack>
          )}

          {posts.length > 0 && (
            <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
              <Pagination
                count={totalPages}
                page={page}
                onChange={handlePageChange}
                color="primary"
                showFirstButton
                showLastButton
                disabled={loading}
              />
            </Box>
          )}
        </>
      )}
    </Box>

);
};

export default SearchableBlogListing;

This example shows how to:

  1. Implement a search form that works alongside pagination
  2. Reset to the first page when the search query changes
  3. Handle empty search results gracefully
  4. Disable pagination controls during loading states

Infinite Scroll with MUI Pagination

While traditional pagination uses discrete pages, infinite scroll is another popular pattern. We can combine both approaches:

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { 
  Box, 
  Card, 
  CardContent, 
  Typography, 
  Pagination, 
  Stack,
  CircularProgress,
  Alert,
  Button
} from '@mui/material';

const POSTS_PER_PAGE = 5;

const InfiniteScrollWithPagination = () => {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [hasMore, setHasMore] = useState(true);

// Ref for infinite scroll observation
const observer = useRef();
const lastPostElementRef = useCallback(node => {
if (loading) return;
if (observer.current) observer.current.disconnect();

    observer.current = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasMore && page < totalPages) {
        setPage(prevPage => prevPage + 1);
      }
    });

    if (node) observer.current.observe(node);

}, [loading, hasMore, page, totalPages]);

// Fetch posts
useEffect(() => {
const fetchPosts = async () => {
setLoading(true);
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${POSTS_PER_PAGE}`
);

        const totalCount = parseInt(response.headers.get('x-total-count') || '0', 10);
        setTotalPages(Math.ceil(totalCount / POSTS_PER_PAGE));

        if (!response.ok) {
          throw new Error('Failed to fetch posts');
        }

        const newPosts = await response.json();

        // For infinite scroll, append new posts instead of replacing
        setPosts(prevPosts => {
          // If it's the first page, replace posts; otherwise, append
          if (page === 1) return newPosts;
          // Filter out duplicates (important for real APIs)
          const existingIds = new Set(prevPosts.map(p => p.id));
          const uniqueNewPosts = newPosts.filter(p => !existingIds.has(p.id));
          return [...prevPosts, ...uniqueNewPosts];
        });

        setHasMore(newPosts.length > 0 && page < Math.ceil(totalCount / POSTS_PER_PAGE));
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchPosts();

}, [page]);

// Handle direct page navigation
const handlePageChange = (event, newPage) => {
// Reset posts when directly navigating
setPosts([]);
setPage(newPage);
window.scrollTo({ top: 0, behavior: 'smooth' });
};

// Reset to first page
const handleReset = () => {
setPosts([]);
setPage(1);
window.scrollTo({ top: 0, behavior: 'smooth' });
};

return (
<Box sx={{ maxWidth: 800, mx: 'auto', py: 4, px: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 4 }}>
<Typography variant="h4" component="h1">
Blog Posts
</Typography>
<Button
variant="outlined"
onClick={handleReset}
disabled={page === 1 && posts.length <= POSTS_PER_PAGE} >
Back to Start
</Button>
</Box>

      {error ? (
        <Alert severity="error" sx={{ my: 2 }}>
          {error}
        </Alert>
      ) : (
        <>
          <Stack spacing={3} sx={{ my: 4 }}>
            {posts.map((post, index) => {
              // Apply ref to last element for infinite scroll
              const isLastElement = index === posts.length - 1;
              return (
                <Card
                  key={post.id}
                  sx={{ boxShadow: 2 }}
                  ref={isLastElement ? lastPostElementRef : undefined}
                >
                  <CardContent>
                    <Typography variant="h6" component="h2" gutterBottom>
                      {post.title}
                    </Typography>
                    <Typography variant="body2" color="text.secondary">
                      {post.body}
                    </Typography>
                    {isLastElement && loading && (
                      <Box sx={{ display: 'flex', justifyContent: 'center', mt: 2 }}>
                        <CircularProgress size={24} />
                      </Box>
                    )}
                  </CardContent>
                </Card>
              );
            })}
          </Stack>

          {/* Traditional pagination as an alternative navigation method */}
          <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
            <Pagination
              count={totalPages}
              page={page}
              onChange={handlePageChange}
              color="primary"
              showFirstButton
              showLastButton
            />
          </Box>

          {/* Loading indicator at the bottom for initial load */}
          {loading && posts.length === 0 && (
            <Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
              <CircularProgress />
            </Box>
          )}
        </>
      )}
    </Box>

);
};

export default InfiniteScrollWithPagination;

This hybrid approach provides:

  1. Automatic loading of the next page when the user scrolls to the bottom
  2. Traditional pagination controls for direct navigation to specific pages
  3. A smooth experience that works well on both desktop and mobile devices

Accessibility Considerations

Ensuring your pagination is accessible is crucial for users with disabilities. MUI's Pagination component has built-in accessibility features, but there are additional considerations:

import React, { useState } from 'react';
import { 
  Box, 
  Pagination, 
  PaginationItem,
  Typography,
  VisuallyHidden
} from '@mui/material';
import { Link } from 'react-router-dom';

const AccessiblePagination = () => {
const [page, setPage] = useState(1);
const totalPages = 10;

const handleChange = (event, value) => {
setPage(value);
// Announce page change to screen readers
document.getElementById('pagination-announcement').textContent =
`Navigated to page ${value} of ${totalPages}`;
};

return (
<Box sx={{ my: 4 }}>
{/* Visually hidden element for screen reader announcements */}
<VisuallyHidden>
<div 
          id="pagination-announcement" 
          aria-live="polite" 
          aria-atomic="true"
        >
Page {page} of {totalPages}
</div>
</VisuallyHidden>

      <Typography
        id="pagination-heading"
        variant="h6"
        sx={{ mb: 2 }}
      >
        Blog Posts - Page {page} of {totalPages}
      </Typography>

      <Pagination
        count={totalPages}
        page={page}
        onChange={handleChange}
        color="primary"
        // Explicitly connect to the heading with aria-labelledby
        aria-labelledby="pagination-heading"
        // Render items as links for better accessibility
        renderItem={(item) => (
          <PaginationItem
            component={Link}
            to={`?page=${item.page}`}
            // Enhanced aria labels for screen readers
            aria-label={`Go to ${
              item.type === 'page'
                ? `page ${item.page}`
                : item.type === 'previous'
                  ? 'previous page'
                  : item.type === 'next'
                    ? 'next page'
                    : item.type === 'first'
                      ? 'first page'
                      : 'last page'
            }`}
            {...item}
          />
        )}
        // Include navigation buttons
        showFirstButton
        showLastButton
      />
    </Box>

);
};

export default AccessiblePagination;

Key accessibility enhancements in this example:

  1. Descriptive ARIA Labels: Clearly communicate the purpose of each pagination item
  2. Screen Reader Announcements: Dynamically announce page changes
  3. Semantic HTML: Proper heading and labeling relationships
  4. Keyboard Navigation: MUI's built-in keyboard support is preserved
  5. Link-based Navigation: Using proper links for SEO and accessibility

Performance Optimization

For applications with large datasets, pagination performance can become an issue. Here are some optimization techniques:

Debouncing Page Changes

import React, { useState, useEffect, useCallback } from 'react';
import { Box, Pagination, CircularProgress } from '@mui/material';
import { debounce } from 'lodash';

const DebouncedPagination = () => {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);

// Debounced data fetching function
const fetchData = useCallback(
debounce(async (pageNumber) => {
setLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));

        // Fetch data for the specified page
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/posts?_page=${pageNumber}&_limit=10`
        );

        const result = await response.json();
        setData(result);
      } catch (error) {
        console.error('Error fetching data:', error);
      } finally {
        setLoading(false);
      }
    }, 300), // 300ms debounce delay
    []

);

useEffect(() => {
fetchData(page);
}, [page, fetchData]);

const handlePageChange = (event, newPage) => {
setPage(newPage);
};

return (
<Box sx={{ position: 'relative', minHeight: '200px' }}>
{loading && (
<Box
sx={{
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: 'rgba(255, 255, 255, 0.7)',
            zIndex: 1,
          }} >
<CircularProgress />
</Box>
)}

      <Box sx={{ opacity: loading ? 0.5 : 1 }}>
        {/* Display your data here */}
        {data.map(item => (
          <div key={item.id}>{item.title}</div>
        ))}
      </Box>

      <Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
        <Pagination
          count={10}
          page={page}
          onChange={handlePageChange}
          color="primary"
          siblingCount={1}
          boundaryCount={1}
        />
      </Box>
    </Box>

);
};

export default DebouncedPagination;

This example uses debouncing to prevent excessive API calls when the user quickly navigates through pages.

Virtual Scrolling for Large Datasets

For extremely large datasets, combining pagination with virtual scrolling can significantly improve performance:

import React, { useState, useEffect } from 'react';
import { 
  Box, 
  Pagination, 
  Typography, 
  CircularProgress 
} from '@mui/material';
import { FixedSizeList as List } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';

const ITEMS_PER_PAGE = 100; // Large number of items per page
const TOTAL_ITEMS = 10000; // Very large dataset

const VirtualizedPagination = () => {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [items, setItems] = useState([]);
const totalPages = Math.ceil(TOTAL_ITEMS / ITEMS_PER_PAGE);

useEffect(() => {
const fetchPageData = async () => {
setLoading(true);

      // Simulate API fetch with delay
      await new Promise(resolve => setTimeout(resolve, 300));

      // Generate mock data for the current page
      const startIndex = (page - 1) * ITEMS_PER_PAGE;
      const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, TOTAL_ITEMS);

      const newItems = Array.from({ length: endIndex - startIndex }, (_, i) => ({
        id: startIndex + i + 1,
        title: `Item ${startIndex + i + 1}`,
        content: `This is the content for item ${startIndex + i + 1}`
      }));

      setItems(newItems);
      setLoading(false);
    };

    fetchPageData();

}, [page]);

const handlePageChange = (event, newPage) => {
setPage(newPage);
window.scrollTo(0, 0);
};

// Row renderer for virtualized list
const Row = ({ index, style }) => {
const item = items[index];

    return (
      <div
        style={{
          ...style,
          display: 'flex',
          flexDirection: 'column',
          padding: '12px',
          borderBottom: '1px solid #eee',
        }}
      >
        <Typography variant="subtitle1" component="div">
          {item.title}
        </Typography>
        <Typography variant="body2" color="text.secondary">
          {item.content}
        </Typography>
      </div>
    );

};

return (
<Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="h5" gutterBottom>
Large Dataset with Virtualization
</Typography>
<Typography variant="body2" color="text.secondary">
Viewing page {page} of {totalPages} ({ITEMS_PER_PAGE} items per page)
</Typography>
</Box>

      <Box sx={{ flex: 1, position: 'relative' }}>
        {loading ? (
          <Box
            sx={{
              display: 'flex',
              justifyContent: 'center',
              alignItems: 'center',
              height: '100%'
            }}
          >
            <CircularProgress />
          </Box>
        ) : (
          <AutoSizer>
            {({ height, width }) => (
              <List
                height={height}
                width={width}
                itemCount={items.length}
                itemSize={72} // Height of each row
              >
                {Row}
              </List>
            )}
          </AutoSizer>
        )}
      </Box>

      <Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', display: 'flex', justifyContent: 'center' }}>
        <Pagination
          count={totalPages}
          page={page}
          onChange={handlePageChange}
          color="primary"
          siblingCount={1}
          boundaryCount={1}
        />
      </Box>
    </Box>

);
};

export default VirtualizedPagination;

This advanced example:

  1. Uses react-window for efficient rendering of large lists
  2. Handles pagination for a dataset with thousands of items
  3. Only renders the visible items in the viewport, significantly improving performance
  4. Maintains the traditional pagination UI for navigation

Best Practices and Common Issues

Best Practices for MUI Pagination

  1. Keep Accessibility in Mind: Always ensure your pagination is accessible to all users, including those using screen readers or keyboard navigation.

  2. Provide Context: Always show the current page and total pages to help users understand where they are.

  3. Consistent Placement: Position pagination controls consistently throughout your application, typically at the bottom of the content.

  4. URL Synchronization: Sync pagination state with the URL to enable bookmarking and sharing specific pages.

  5. Responsive Design: Adjust the number of visible page buttons based on screen size using the siblingCount and boundaryCount props.

  6. Loading States: Clearly indicate when content is loading after a page change.

  7. Combine with Other Navigation: For long lists, consider combining pagination with "Back to Top" buttons or sticky headers.

Common Issues and Solutions

Issue 1: Pagination Resets on Component Rerender

Problem: The pagination resets to page 1 whenever the component rerenders.

Solution: Store the current page in state or URL parameters:

import React, { useState, useEffect } from 'react';
import { Pagination, Box } from '@mui/material';

const PersistentPagination = () => {
// Store page in state
const [page, setPage] = useState(() => {
// Initialize from URL or localStorage if needed
const urlParams = new URLSearchParams(window.location.search);
const pageParam = urlParams.get('page');
return pageParam ? parseInt(pageParam, 10) : 1;
});

// Update URL when page changes
useEffect(() => {
const url = new URL(window.location);
if (page === 1) {
url.searchParams.delete('page');
} else {
url.searchParams.set('page', page.toString());
}
window.history.replaceState({}, '', url);

    // Optionally save to localStorage for persistence across sessions
    localStorage.setItem('currentPage', page.toString());

}, [page]);

return (
<Box sx={{ my: 2 }}>
<Pagination
count={10}
page={page}
onChange={(_, newPage) => setPage(newPage)}
color="primary"
/>
</Box>
);
};

export default PersistentPagination;

Issue 2: Pagination Doesn't Work with Filtered Data

Problem: When filtering data, pagination doesn't adjust correctly.

Solution: Reset to page 1 when filters change:

import React, { useState, useEffect } from 'react';
import { 
  Box, 
  Pagination, 
  TextField, 
  FormControl, 
  InputLabel, 
  Select, 
  MenuItem 
} from '@mui/material';

const FilterablePagination = () => {
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState('');
const [category, setCategory] = useState('all');
const [filteredData, setFilteredData] = useState([]);
const [totalPages, setTotalPages] = useState(1);

// This would typically be your API call
useEffect(() => {
const fetchData = async () => {
// Reset to page 1 whenever filters change
const newPage = 1;
setPage(newPage);

      // Simulate API call with filters
      const response = await fetch(
        `/api/data?page=${newPage}&search=${searchTerm}&category=${category}`
      );

      const data = await response.json();
      setFilteredData(data.items);
      setTotalPages(data.totalPages);
    };

    fetchData();

}, [searchTerm, category]); // Note: page is not a dependency here

// This effect handles page changes without changing filters
useEffect(() => {
const fetchPage = async () => {
const response = await fetch(
`/api/data?page=${page}&search=${searchTerm}&category=${category}`
);

      const data = await response.json();
      setFilteredData(data.items);
    };

    fetchPage();

}, [page, searchTerm, category]);

return (
<Box>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<TextField
label="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
size="small"
/>

        <FormControl size="small" sx={{ minWidth: 120 }}>
          <InputLabel>Category</InputLabel>
          <Select
            value={category}
            label="Category"
            onChange={(e) => setCategory(e.target.value)}
          >
            <MenuItem value="all">All</MenuItem>
            <MenuItem value="electronics">Electronics</MenuItem>
            <MenuItem value="books">Books</MenuItem>
          </Select>
        </FormControl>
      </Box>

      {/* Display your filtered data here */}

      <Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
        <Pagination
          count={totalPages}
          page={page}
          onChange={(_, newPage) => setPage(newPage)}
          color="primary"
        />
      </Box>
    </Box>

);
};

export default FilterablePagination;

Issue 3: Handling Edge Cases with Few Items

Problem: Pagination looks odd when there are very few items.

Solution: Conditionally render pagination only when needed:

import React, { useState, useEffect } from 'react';
import { Box, Pagination, Typography } from '@mui/material';

const SmartPagination = ({ items, itemsPerPage = 10 }) => {
const [page, setPage] = useState(1);
const totalPages = Math.ceil(items.length / itemsPerPage);

// Ensure current page is valid if total pages changes
useEffect(() => {
if (page > totalPages && totalPages > 0) {
setPage(totalPages);
}
}, [totalPages, page]);

// Get current page items
const currentItems = items.slice(
(page - 1) _ itemsPerPage,
page _ itemsPerPage
);

return (
<Box>
{/* Display current items */}
<Box sx={{ mb: 3 }}>
{currentItems.map(item => (
<div key={item.id}>{item.title}</div>
))}
</Box>

      {/* Conditionally render pagination */}
      {totalPages > 1 ? (
        <Box sx={{ display: 'flex', justifyContent: 'center' }}>
          <Pagination
            count={totalPages}
            page={page}
            onChange={(_, newPage) => setPage(newPage)}
            color="primary"
            size={totalPages > 7 ? 'medium' : 'large'} // Adjust size based on page count
          />
        </Box>
      ) : items.length > 0 ? (
        <Typography variant="body2" color="text.secondary" align="center">
          Showing all {items.length} items
        </Typography>
      ) : (
        <Typography variant="body2" color="text.secondary" align="center">
          No items found
        </Typography>
      )}
    </Box>

);
};

export default SmartPagination;

Wrapping Up

MUI's Pagination component offers a versatile solution for implementing page navigation in React applications. We've explored everything from basic setup to advanced customization and integration patterns.

To build effective pagination, remember to focus on user experience, accessibility, and performance. Consider your specific use case to determine whether traditional pagination, infinite scroll, or a hybrid approach works best for your application.

By following the patterns and practices outlined in this guide, you can create pagination that not only looks good but also enhances the usability of your application, making it easier for users to navigate through large datasets while maintaining optimal performance.