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:
- Visual Variants: Standard, outlined, and rounded styles to match your design system
- Size Options: Small, medium, and large sizes for different UI contexts
- Customizable Range: Control how many page numbers are displayed
- Navigation Controls: Optional previous/next buttons and first/last page buttons
- Accessibility Support: Built-in keyboard navigation and screen reader compatibility
- 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.
Prop | Type | Default | Description |
---|---|---|---|
count | number | 1 | Number of pages |
page | number | - | Current page (for controlled component) |
defaultPage | number | 1 | Default page (for uncontrolled component) |
onChange | function | - | Callback fired when page changes |
color | 'primary' | 'secondary' | 'standard' | 'standard' | Color of the component |
disabled | boolean | false | If true, the component is disabled |
hideNextButton | boolean | false | If true, hide the next-page button |
hidePrevButton | boolean | false | If true, hide the previous-page button |
showFirstButton | boolean | false | If true, show the first-page button |
showLastButton | boolean | false | If true, show the last-page button |
size | 'small' | 'medium' | 'large' | 'medium' | The size of the component |
siblingCount | number | 1 | Number 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 |
boundaryCount | number | 1 | Number 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:
- We create a controlled component using React's
useState
hook to track the current page - We set a total number of pages (in a real application, this would come from your API or data source)
- We implement a change handler to update the page state when the user clicks on a different page
- 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:
- API Integration: Fetching paginated data from an API
- Loading States: Displaying a loading indicator while fetching data
- Error Handling: Showing an error message if the API request fails
- Dynamic Pagination: Calculating the total number of pages based on API response
- 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:
- Reads the initial page from the URL query parameter
- Updates the URL when the page changes
- Maintains browser history correctly
- 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:
- Implement a search form that works alongside pagination
- Reset to the first page when the search query changes
- Handle empty search results gracefully
- 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:
- Automatic loading of the next page when the user scrolls to the bottom
- Traditional pagination controls for direct navigation to specific pages
- 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:
- Descriptive ARIA Labels: Clearly communicate the purpose of each pagination item
- Screen Reader Announcements: Dynamically announce page changes
- Semantic HTML: Proper heading and labeling relationships
- Keyboard Navigation: MUI's built-in keyboard support is preserved
- 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:
- Uses
react-window
for efficient rendering of large lists - Handles pagination for a dataset with thousands of items
- Only renders the visible items in the viewport, significantly improving performance
- Maintains the traditional pagination UI for navigation
Best Practices and Common Issues
Best Practices for MUI Pagination
-
Keep Accessibility in Mind: Always ensure your pagination is accessible to all users, including those using screen readers or keyboard navigation.
-
Provide Context: Always show the current page and total pages to help users understand where they are.
-
Consistent Placement: Position pagination controls consistently throughout your application, typically at the bottom of the content.
-
URL Synchronization: Sync pagination state with the URL to enable bookmarking and sharing specific pages.
-
Responsive Design: Adjust the number of visible page buttons based on screen size using the
siblingCount
andboundaryCount
props. -
Loading States: Clearly indicate when content is loading after a page change.
-
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.