Building Responsive Product Grid Layouts with React MUI Grid v2
As a front-end developer working with React and Material UI, creating responsive grid layouts is a fundamental skill you'll need for nearly every project. When I first started working with MUI's Grid system years ago, I struggled with its nuances—especially when building product layouts that needed to look great on every device. Today, I'll walk you through using MUI Grid v2, the latest iteration of MUI's grid system, to build a responsive product grid that adapts beautifully across all screen sizes.
What You'll Learn in This Guide
In this comprehensive guide, we'll cover:
- Understanding MUI Grid v2's core concepts and how it differs from Grid v1
- Setting up a responsive product grid layout from scratch
- Mastering responsive breakpoints for different screen sizes
- Implementing advanced Grid v2 features like nested grids and spacing control
- Optimizing your product grid for performance and accessibility
- Troubleshooting common Grid v2 issues
By the end of this tutorial, you'll have built a professional-quality, responsive product grid layout that can serve as the foundation for e-commerce sites, portfolios, or any application that needs to display collections of items.
Understanding MUI Grid v2: The Foundation
MUI Grid v2 is a complete reimagining of the original Grid component. It was introduced to address limitations in the original implementation while providing more flexibility and better performance. Before we dive into code, let's understand the fundamentals.
Grid v2 is based on CSS Grid Layout, a powerful two-dimensional layout system that represents a significant improvement over the flexbox-based Grid v1. This shift allows for more complex layouts with less code and better performance. If you've used Grid v1 before, you'll need to adjust your thinking slightly, but the benefits are well worth it.
One key difference is that Grid v2 is available as a separate package, which helps keep bundle sizes down if you don't need it. This modular approach reflects MUI's commitment to performance and flexibility.
Installation and Basic Setup
Let's start by installing the necessary packages. You'll need both the core MUI package and the separate Grid v2 package.
npm install @mui/material @mui/system @mui/material-nextjs @emotion/react @emotion/styled
npm install @mui/material-next
Now, let's create a basic product grid layout to display a collection of products. We'll start with a simple implementation and then refine it.
import React from 'react';
import { Grid } from '@mui/material-next/Grid'; // Note we import from material-next
import { Card, CardContent, CardMedia, Typography, Box } from '@mui/material';
// Sample product data
const products = [
{
id: 1,
name: 'Wireless Headphones',
price: 99.99,
image: 'https://source.unsplash.com/random/300x200/?headphones',
description: 'Premium wireless headphones with noise cancellation.'
},
{
id: 2,
name: 'Smartphone',
price: 799.99,
image: 'https://source.unsplash.com/random/300x200/?smartphone',
description: 'Latest model with high-resolution camera and fast processor.'
},
{
id: 3,
name: 'Laptop',
price: 1299.99,
image: 'https://source.unsplash.com/random/300x200/?laptop',
description: 'Powerful laptop for professional use with long battery life.'
},
{
id: 4,
name: 'Smartwatch',
price: 249.99,
image: 'https://source.unsplash.com/random/300x200/?smartwatch',
description: 'Track your fitness and stay connected with this smartwatch.'
}
];
const ProductGrid = () => {
return (
<Box sx={{ flexGrow: 1, padding: 2 }}>
<Grid container spacing={2}>
{products.map((product) => (
<Grid xs={12} sm={6} md={4} lg={3} key={product.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardMedia
component="img"
height="140"
image={product.image}
alt={product.name}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="h2">
{product.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{product.description}
</Typography>
<Typography variant="h6" color="primary" sx={{ mt: 2 }}>
${product.price.toFixed(2)}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
);
};
export default ProductGrid;
In this basic implementation, we've created a product grid that:
- Uses the new Grid v2 component from '@mui/material-next/Grid'
- Creates a responsive layout that shows 1 product per row on extra-small screens, 2 on small screens, 3 on medium screens, and 4 on large screens
- Uses MUI Card components to display each product with an image, name, description, and price
This is a solid starting point, but let's dive deeper into Grid v2's capabilities to create a more refined product grid.
MUI Grid v2 Deep Dive
Component Props
Grid v2 comes with a rich set of props that give you precise control over your layout. Let's explore the most important ones:
Prop | Type | Default | Description |
---|---|---|---|
container | boolean | false | If true, the component will act as a grid container |
spacing | number | object | 0 | Defines the space between grid items |
xs, sm, md, lg, xl | number | boolean | 'auto' | object | false | Defines the number of columns the grid item should span for different breakpoints |
columns | number | object | 12 | Defines the total number of columns in the grid |
rowSpacing | number | object | 0 | Defines the vertical space between grid items |
columnSpacing | number | object | 0 | Defines the horizontal space between grid items |
disableEqualOverflow | boolean | false | If true, the negative margin that compensates for the spacing between items won't be applied |
direction | 'row' | 'row-reverse' | 'column' | 'column-reverse' | 'row' | Defines the flex-direction of the grid container |
One of the key differences between Grid v1 and v2 is that in v2, you don't need to specify the item
prop for grid items. In v2, every direct child of a Grid container is automatically treated as a grid item.
Responsive Breakpoints
Grid v2 makes it easy to create responsive layouts by providing props that correspond to MUI's breakpoints:
- xs: Extra-small screens (< 600px)
- sm: Small screens (≥ 600px)
- md: Medium screens (≥ 900px)
- lg: Large screens (≥ 1200px)
- xl: Extra-large screens (≥ 1536px)
For each breakpoint, you can specify how many columns a grid item should span. The grid is divided into 12 columns by default, so a value of 6 means the item will take up half the width of the container.
Let's see how to use these breakpoints to create a more responsive product grid:
<Grid container spacing={{ xs: 1, sm: 2, md: 3 }}>
{products.map((product) => (
<Grid xs={12} sm={6} md={4} lg={3} xl={2} key={product.id}>
{/* Card content */}
</Grid>
))}
</Grid>
This code creates a grid where:
- On extra-small screens, each product takes up the full width (12/12 columns)
- On small screens, two products per row (6/12 columns each)
- On medium screens, three products per row (4/12 columns each)
- On large screens, four products per row (3/12 columns each)
- On extra-large screens, six products per row (2/12 columns each)
Additionally, we've used the object syntax for the spacing
prop to apply different spacing at different breakpoints.
Customization
Grid v2 offers several ways to customize its appearance and behavior. Let's explore the most common methods:
Using the sx
Prop
The sx
prop is the most direct way to apply custom styles to MUI components. It allows you to use theme-aware values and responsive breakpoints.
<Grid
container
sx={{
backgroundColor: 'background.paper',
borderRadius: 1,
p: { xs: 1, md: 2 },
'& > .MuiGrid-item': {
transition: 'transform 0.3s ease-in-out',
'&:hover': {
transform: 'translateY(-4px)'
}
}
}}
>
{/* Grid items */}
</Grid>
This example applies a paper background to the container, adds responsive padding, and includes a hover effect for all grid items.
Theme Customization
For global customization, you can modify the MUI theme to change how Grid v2 components look throughout your application:
import { createTheme, ThemeProvider } from '@mui/material/styles';
const theme = createTheme({
components: {
MuiGrid2: {
styleOverrides: {
root: {
'&.MuiGrid2-container': {
margin: '0 auto',
maxWidth: '1200px',
},
'&.MuiGrid2-item': {
padding: '16px',
},
},
},
},
},
});
function App() {
return (
<ThemeProvider theme={theme}>
<ProductGrid />
</ThemeProvider>
);
}
Using the styled
API
For more complex customizations, you can use the styled
API to create customized versions of the Grid component:
import { styled } from '@mui/material/styles';
import { Grid } from '@mui/material-next/Grid';
const StyledGridItem = styled(Grid)(({ theme }) => ({
'&:hover': {
backgroundColor: theme.palette.action.hover,
},
padding: theme.spacing(2),
transition: 'background-color 0.3s ease',
}));
// Then use it in your component
<Grid container>
{products.map((product) => (
<StyledGridItem xs={12} sm={6} md={4} lg={3} key={product.id}>
{/* Card content */}
</StyledGridItem>
))}
</Grid>
Limitations and Performance Considerations
While Grid v2 is powerful, it's important to be aware of its limitations:
- Deeply nested grids can impact performance. Try to keep your grid structure as flat as possible.
- Large numbers of grid items can cause performance issues. Consider implementing virtualization for long lists.
- Complex responsive behaviors might require custom CSS media queries in addition to the breakpoint props.
For best performance, follow these guidelines:
- Use the
container
prop only when necessary - Avoid unnecessary nesting of Grid components
- Consider using
React.memo
for grid items that don't change often - For very large lists, implement virtualization with libraries like
react-virtualized
orreact-window
Building a Complete Responsive Product Grid
Now that we understand Grid v2's capabilities, let's build a more comprehensive product grid with advanced features. We'll create a product grid that includes:
- Responsive layout with different column counts based on screen size
- Filtering and sorting options
- Loading states and error handling
- Accessibility improvements
Step 1: Create the Basic Structure
First, let's set up our component structure:
import React, { useState } from 'react';
import { Grid } from '@mui/material-next/Grid';
import {
Card, CardContent, CardMedia, Typography, Box,
FormControl, InputLabel, Select, MenuItem,
TextField, CircularProgress, Alert, Button
} from '@mui/material';
const ProductGrid = ({ products: initialProducts, loading = false, error = null }) => {
const [products, setProducts] = useState(initialProducts || []);
const [sortBy, setSortBy] = useState('default');
const [filterText, setFilterText] = useState('');
// We'll implement these functions in the next steps
const handleSort = (event) => {
setSortBy(event.target.value);
};
const handleFilterChange = (event) => {
setFilterText(event.target.value);
};
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 2 }}>
{/* Controls will go here */}
{/* Products grid will go here */}
</Box>
);
};
export default ProductGrid;
Step 2: Add Control Elements
Now, let's add filtering and sorting controls:
// Inside the ProductGrid component
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', p: 2 }}>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid xs={12} md={6}>
<TextField
fullWidth
label="Search products"
variant="outlined"
value={filterText}
onChange={handleFilterChange}
disabled={loading}
/>
</Grid>
<Grid xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="sort-select-label">Sort by</InputLabel>
<Select
labelId="sort-select-label"
value={sortBy}
label="Sort by"
onChange={handleSort}
disabled={loading}
>
<MenuItem value="default">Default</MenuItem>
<MenuItem value="priceLow">Price: Low to High</MenuItem>
<MenuItem value="priceHigh">Price: High to Low</MenuItem>
<MenuItem value="name">Name</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
{/* Products grid will go here */}
</Box>
);
Step 3: Implement Filtering and Sorting Logic
Next, let's add the logic to filter and sort our products:
// Inside the ProductGrid component
// Filter and sort products
const getDisplayedProducts = () => {
// First filter the products
let filteredProducts = initialProducts;
if (filterText) {
const searchTerm = filterText.toLowerCase();
filteredProducts = initialProducts.filter(
product =>
product.name.toLowerCase().includes(searchTerm) ||
product.description.toLowerCase().includes(searchTerm)
);
}
// Then sort them
switch (sortBy) {
case 'priceLow':
return [...filteredProducts].sort((a, b) => a.price - b.price);
case 'priceHigh':
return [...filteredProducts].sort((a, b) => b.price - a.price);
case 'name':
return [...filteredProducts].sort((a, b) => a.name.localeCompare(b.name));
default:
return filteredProducts;
}
};
const displayedProducts = getDisplayedProducts();
Step 4: Create the Product Grid with Loading and Error States
Now, let's implement the product grid with proper handling of loading and error states:
// Inside the ProductGrid component return statement, after the controls
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 4 }}>
<CircularProgress />
</Box>
) : displayedProducts.length === 0 ? (
<Alert severity="info" sx={{ mb: 2 }}>
No products found matching your criteria.
</Alert>
) : (
<Grid
container
spacing={{ xs: 1, sm: 2, md: 3 }}
columns={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 6 }}
>
{displayedProducts.map((product) => (
<Grid xs={1} key={product.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 6
}
}}
>
<CardMedia
component="img"
height="200"
image={product.image}
alt={product.name}
sx={{ objectFit: 'cover' }}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="h2">
{product.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{product.description}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}>
<Typography variant="h6" color="primary">
${product.price.toFixed(2)}
</Typography>
<Button variant="contained" size="small">
Add to Cart
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
Note the improvements in this implementation:
- We're using the
columns
prop to define the number of columns for each breakpoint - We've added hover effects to the cards for better user interaction
- We've included proper loading and empty state handling
- We've added an "Add to Cart" button for each product
Step 5: Enhance Accessibility
Let's make our product grid more accessible:
// Inside the Card component
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 6
},
'&:focus-within': { // Add keyboard focus styles
outline: '2px solid',
outlineColor: 'primary.main',
}
}}
>
<CardMedia
component="img"
height="200"
image={product.image}
alt={product.name} // Important for screen readers
sx={{ objectFit: 'cover' }}
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="h2">
{product.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{product.description}
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto' }}>
<Typography variant="h6" color="primary">
${product.price.toFixed(2)}
</Typography>
<Button
variant="contained"
size="small"
aria-label={`Add ${product.name} to cart`} // Descriptive label for screen readers
>
Add to Cart
</Button>
</Box>
</CardContent>
</Card>
Step 6: Add Performance Optimizations
For better performance, especially with large product lists, let's implement some optimizations:
import React, { useState, useMemo, memo } from 'react';
// Memoize the product card component to prevent unnecessary re-renders
const ProductCard = memo(({ product }) => {
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 6
},
'&:focus-within': {
outline: '2px solid',
outlineColor: 'primary.main',
}
}}
>
{/* Card content as before */}
</Card>
);
});
// In the main component
const ProductGrid = ({ products: initialProducts, loading = false, error = null }) => {
// State and handlers as before
// Memoize the filtered and sorted products to prevent recalculation on every render
const displayedProducts = useMemo(() => {
// Filter and sort logic as before
return getDisplayedProducts();
}, [initialProducts, filterText, sortBy]);
return (
// JSX as before, but use the memoized ProductCard component
<Grid container spacing={{ xs: 1, sm: 2, md: 3 }} columns={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 6 }}>
{displayedProducts.map((product) => (
<Grid xs={1} key={product.id}>
<ProductCard product={product} />
</Grid>
))}
</Grid>
);
};
Step 7: Implement Advanced Grid Features
Let's explore some advanced Grid v2 features to enhance our product grid:
// Creating a featured product section with different column spans
<Box sx={{ mb: 4 }}>
<Typography variant="h4" component="h2" gutterBottom>
Featured Products
</Typography>
<Grid
container
spacing={2}
disableEqualOverflow // Prevents negative margins at the edges
>
{/* Featured product that spans multiple columns */}
<Grid xs={12} md={8}>
<Card sx={{ display: 'flex', height: '100%' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<CardContent>
<Typography component="h3" variant="h4">
{featuredProduct.name}
</Typography>
<Typography variant="body1" paragraph>
{featuredProduct.description}
</Typography>
<Typography variant="h5" color="primary">
${featuredProduct.price.toFixed(2)}
</Typography>
<Button variant="contained" size="large" sx={{ mt: 2 }}>
View Details
</Button>
</CardContent>
</Box>
<CardMedia
component="img"
sx={{ width: { xs: '100%', md: '50%' }, display: { xs: 'none', md: 'block' } }}
image={featuredProduct.image}
alt={featuredProduct.name}
/>
</Card>
</Grid>
{/* Secondary featured products */}
<Grid xs={12} md={4} sx={{ display: 'flex', flexDirection: 'column' }}>
<Grid container direction="column" spacing={2} sx={{ height: '100%' }}>
<Grid xs>
<Card sx={{ height: '100%' }}>
{/* Secondary featured product 1 */}
</Card>
</Grid>
<Grid xs>
<Card sx={{ height: '100%' }}>
{/* Secondary featured product 2 */}
</Card>
</Grid>
</Grid>
</Grid>
</Grid>
</Box>
This implementation showcases:
- Using different column spans for featured products
- Nesting Grid containers for more complex layouts
- Using the
disableEqualOverflow
prop to prevent negative margins - Responsive design that adapts to different screen sizes
Advanced Capabilities of Grid v2
Auto Layout
Grid v2 supports auto layout, which can be useful when you want the grid to automatically determine the width of items:
<Grid container spacing={2}>
<Grid xs="auto">
<Box sx={{ width: 100, height: 100, bgcolor: 'primary.main' }} />
</Grid>
<Grid xs>
<Box sx={{ height: 100, bgcolor: 'secondary.main' }} />
</Grid>
<Grid xs={2}>
<Box sx={{ height: 100, bgcolor: 'error.main' }} />
</Grid>
</Grid>
In this example:
- The first item (
xs="auto"
) will be sized based on its content - The second item (
xs
) will fill the remaining space - The third item (
xs={2}
) will take up 2 columns
Custom Column Count
By default, Grid v2 uses a 12-column system, but you can customize this:
<Grid container columns={16} spacing={2}>
<Grid xs={8}>
<Box sx={{ height: 100, bgcolor: 'primary.main' }} />
</Grid>
<Grid xs={8}>
<Box sx={{ height: 100, bgcolor: 'secondary.main' }} />
</Grid>
</Grid>
This creates a 16-column grid with two items that each take up half the width.
Responsive Direction
You can change the direction of the grid based on screen size:
<Grid
container
spacing={2}
direction={{ xs: 'column', sm: 'row' }}
>
<Grid xs={12} sm={6}>
<Box sx={{ height: 100, bgcolor: 'primary.main' }} />
</Grid>
<Grid xs={12} sm={6}>
<Box sx={{ height: 100, bgcolor: 'secondary.main' }} />
</Grid>
</Grid>
This grid will display items in a column on mobile and in a row on larger screens.
Different Row and Column Spacing
Grid v2 allows you to set different spacing for rows and columns:
<Grid
container
rowSpacing={3} // Vertical spacing
columnSpacing={2} // Horizontal spacing
>
{/* Grid items */}
</Grid>
This is particularly useful for creating layouts with different visual rhythms in horizontal and vertical directions.
Best Practices for MUI Grid v2
After working with Grid v2 on numerous projects, I've developed some best practices that will help you create more maintainable and performant layouts:
1. Keep Your Grid Structure Flat
Avoid deeply nesting Grid components when possible. Each level of nesting adds complexity and can impact performance. Instead, try to create layouts with a flat hierarchy.
2. Use Responsive Props Consistently
When defining breakpoints, be consistent with your approach. Either define all breakpoints for each item or use the cascading nature of the breakpoints (where values apply to the specified breakpoint and up).
// Good - explicit breakpoints
<Grid xs={12} sm={6} md={4} lg={3}>
// Also good - using cascading behavior
<Grid xs={12} sm={6} md={4}>
// Avoid - mixing approaches inconsistently
<Grid xs={12} md={4}> // Skipping sm can be confusing
3. Use Container Components Wisely
The container
prop creates a new flex container. Only use it when you need to start a new grid system, not for every grouping of elements.
4. Leverage the sx
Prop for One-off Styling
For component-specific styling that doesn't need to be reused, the sx
prop provides a clean way to apply styles without creating custom styled components:
<Grid
xs={12}
md={6}
sx={{
display: 'flex',
alignItems: 'center',
'& img': { maxWidth: '100%' }
}}
>
<img src="product.jpg" alt="Product" />
</Grid>
5. Use Breakpoint Objects for Complex Responsive Behavior
For complex responsive behavior, use the object syntax for breakpoints:
<Grid
xs={{ span: 12, order: 2 }}
md={{ span: 6, order: 1 }}
>
{/* Content */}
</Grid>
This allows you to control multiple aspects of the grid item at each breakpoint.
Common Issues and Solutions
Issue 1: Unexpected Margins or Padding
Problem: Grid items have unexpected margins or padding, causing alignment issues.
Solution: Grid v2 applies negative margins to containers to compensate for item spacing. If you're seeing unexpected spacing, check:
- Make sure you're not adding custom margins to grid items
- Consider using the
disableEqualOverflow
prop if you don't want negative margins - Use the
rowSpacing
andcolumnSpacing
props for more precise control
<Grid
container
disableEqualOverflow
rowSpacing={2}
columnSpacing={2}
>
{/* Grid items */}
</Grid>
Issue 2: Items Not Filling Available Height
Problem: Grid items within a row have different heights, and you want them all to be the same height.
Solution: Grid v2 doesn't automatically equalize heights. You need to ensure all items in a row have the same height:
<Grid container spacing={2}>
{products.map((product) => (
<Grid xs={12} sm={6} md={4} key={product.id}>
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<CardMedia
component="img"
height="140"
image={product.image}
alt={product.name}
sx={{ objectFit: 'cover' }} // Ensure consistent image display
/>
<CardContent sx={{ flexGrow: 1 }}> {/* This will expand to fill available space */}
{/* Card content */}
</CardContent>
</Card>
</Grid>
))}
</Grid>
Issue 3: Performance with Large Lists
Problem: Performance issues when rendering large product lists.
Solution: Implement virtualization for long lists:
import React, { useState } from 'react';
import { Grid } from '@mui/material-next/Grid';
import { Box } from '@mui/material';
import { FixedSizeGrid as VirtualGrid } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
const VirtualizedProductGrid = ({ products }) => {
// Calculate columns based on viewport width
const getColumnCount = (width) => {
if (width < 600) return 1;
if (width < 960) return 2;
if (width < 1280) return 3;
return 4;
};
const Cell = ({ columnIndex, rowIndex, style, data }) => {
const { products, columnCount } = data;
const index = rowIndex * columnCount + columnIndex;
if (index >= products.length) return null;
const product = products[index];
return (
<div style={style}>
<Box sx={{ p: 1, height: '100%' }}>
{/* Your product card component */}
<Card sx={{ height: '100%' }}>
{/* Card content */}
</Card>
</Box>
</div>
);
};
return (
<Box sx={{ height: 600, width: '100%' }}>
<AutoSizer>
{({ height, width }) => {
const columnCount = getColumnCount(width);
const rowCount = Math.ceil(products.length / columnCount);
return (
<VirtualGrid
columnCount={columnCount}
columnWidth={width / columnCount}
height={height}
rowCount={rowCount}
rowHeight={300} // Adjust based on your card height
width={width}
itemData={{ products, columnCount }}
>
{Cell}
</VirtualGrid>
);
}}
</AutoSizer>
</Box>
);
};
This implementation uses react-window
for virtualization, rendering only the items currently in view, which dramatically improves performance for large lists.
Issue 4: Grid Items Not Wrapping Correctly
Problem: Grid items don't wrap as expected on smaller screens.
Solution: Make sure you're specifying the correct breakpoint props and not overriding the flexbox wrapping behavior:
<Grid
container
spacing={2}
sx={{ flexWrap: 'wrap' }} // Ensure wrapping is enabled
>
{products.map((product) => (
<Grid
xs={12}
sm={6}
md={4}
key={product.id}
sx={{ minWidth: 0 }} // Ensure items can shrink below their content size
>
{/* Product card */}
</Grid>
))}
</Grid>
Complete Product Grid Implementation
Now, let's put everything together to create a complete, production-ready product grid component:
import React, { useState, useMemo, memo } from 'react';
import { Grid } from '@mui/material-next/Grid';
import {
Card, CardContent, CardMedia, Typography, Box,
FormControl, InputLabel, Select, MenuItem,
TextField, CircularProgress, Alert, Button,
Pagination, Chip, Rating, IconButton, Skeleton
} from '@mui/material';
import FavoriteIcon from '@mui/icons-material/Favorite';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
// Memoized product card component for better performance
const ProductCard = memo(({
product,
onAddToCart,
onToggleFavorite,
isFavorite
}) => {
return (
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 6
},
'&:focus-within': {
outline: '2px solid',
outlineColor: 'primary.main',
}
}}
>
<Box sx={{ position: 'relative' }}>
<CardMedia
component="img"
height="200"
image={product.image}
alt={product.name}
sx={{ objectFit: 'cover' }}
/>
<IconButton
aria-label={isFavorite ? `Remove ${product.name} from favorites` : `Add ${product.name} to favorites`}
sx={{
position: 'absolute',
top: 8,
right: 8,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'background.paper' }
}}
onClick={() => onToggleFavorite(product.id)}
>
{isFavorite ? <FavoriteIcon color="error" /> : <FavoriteBorderIcon />}
</IconButton>
{product.discount > 0 && (
<Chip
label={`${product.discount}% OFF`}
color="error"
size="small"
sx={{
position: 'absolute',
top: 8,
left: 8,
}}
/>
)}
</Box>
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ mb: 1 }}>
<Rating value={product.rating} precision={0.5} readOnly size="small" />
<Typography variant="body2" color="text.secondary">
({product.reviewCount} reviews)
</Typography>
</Box>
<Typography gutterBottom variant="h5" component="h2">
{product.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{product.description}
</Typography>
<Box sx={{ mt: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
{product.discount > 0 ? (
<>
<Typography variant="h6" color="primary" component="span">
${((product.price * (100 - product.discount)) / 100).toFixed(2)}
</Typography>
<Typography
variant="body2"
color="text.secondary"
component="span"
sx={{ ml: 1, textDecoration: 'line-through' }}
>
${product.price.toFixed(2)}
</Typography>
</>
) : (
<Typography variant="h6" color="primary">
${product.price.toFixed(2)}
</Typography>
)}
</Box>
<Button
variant="contained"
size="small"
startIcon={<ShoppingCartIcon />}
onClick={() => onAddToCart(product)}
aria-label={`Add ${product.name} to cart`}
>
Add
</Button>
</Box>
</CardContent>
</Card>
);
});
// Loading skeleton for product cards
const ProductCardSkeleton = () => (
<Card sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Skeleton variant="rectangular" height={200} />
<CardContent sx={{ flexGrow: 1 }}>
<Skeleton variant="text" width="40%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="70%" height={28} sx={{ mb: 1 }} />
<Skeleton variant="text" width="100%" height={20} />
<Skeleton variant="text" width="100%" height={20} />
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Skeleton variant="text" width="30%" height={32} />
<Skeleton variant="rectangular" width="25%" height={36} />
</Box>
</CardContent>
</Card>
);
const ProductGrid = ({
products: initialProducts = [],
loading = false,
error = null,
itemsPerPage = 12
}) => {
const [currentPage, setCurrentPage] = useState(1);
const [sortBy, setSortBy] = useState('default');
const [filterText, setFilterText] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [favorites, setFavorites] = useState([]);
const [cart, setCart] = useState([]);
// Extract unique categories from products
const categories = useMemo(() => {
if (!initialProducts.length) return ['all'];
const uniqueCategories = new Set(initialProducts.map(p => p.category));
return ['all', ...Array.from(uniqueCategories)];
}, [initialProducts]);
// Handle sorting change
const handleSort = (event) => {
setSortBy(event.target.value);
setCurrentPage(1); // Reset to first page when sorting changes
};
// Handle filter text change
const handleFilterChange = (event) => {
setFilterText(event.target.value);
setCurrentPage(1); // Reset to first page when filter changes
};
// Handle category filter change
const handleCategoryChange = (event) => {
setCategoryFilter(event.target.value);
setCurrentPage(1); // Reset to first page when category changes
};
// Handle page change
const handlePageChange = (event, value) => {
setCurrentPage(value);
// Scroll to top of grid when page changes
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// Add to cart handler
const handleAddToCart = (product) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) {
return prevCart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
return [...prevCart, { ...product, quantity: 1 }];
}
});
};
// Toggle favorite handler
const handleToggleFavorite = (productId) => {
setFavorites(prevFavorites => {
if (prevFavorites.includes(productId)) {
return prevFavorites.filter(id => id !== productId);
} else {
return [...prevFavorites, productId];
}
});
};
// Filter and sort products
const filteredAndSortedProducts = useMemo(() => {
// First filter the products
let result = [...initialProducts];
// Apply category filter
if (categoryFilter !== 'all') {
result = result.filter(product => product.category === categoryFilter);
}
// Apply text filter
if (filterText) {
const searchTerm = filterText.toLowerCase();
result = result.filter(
product =>
product.name.toLowerCase().includes(searchTerm) ||
product.description.toLowerCase().includes(searchTerm)
);
}
// Then sort them
switch (sortBy) {
case 'priceLow':
return result.sort((a, b) => a.price - b.price);
case 'priceHigh':
return result.sort((a, b) => b.price - a.price);
case 'name':
return result.sort((a, b) => a.name.localeCompare(b.name));
case 'rating':
return result.sort((a, b) => b.rating - a.rating);
case 'newest':
return result.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
default:
return result;
}
}, [initialProducts, filterText, sortBy, categoryFilter]);
// Paginate products
const paginatedProducts = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredAndSortedProducts.slice(startIndex, startIndex + itemsPerPage);
}, [filteredAndSortedProducts, currentPage, itemsPerPage]);
// Calculate total pages
const totalPages = Math.ceil(filteredAndSortedProducts.length / itemsPerPage);
return (
<Box sx={{ maxWidth: 1200, mx: 'auto', p: { xs: 1, sm: 2 } }}>
{/* Filter and sort controls */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid xs={12} sm={6} md={3}>
<TextField
fullWidth
label="Search products"
variant="outlined"
value={filterText}
onChange={handleFilterChange}
disabled={loading}
InputProps={{
sx: { borderRadius: 2 }
}}
/>
</Grid>
<Grid xs={12} sm={6} md={3}>
<FormControl fullWidth>
<InputLabel id="category-select-label">Category</InputLabel>
<Select
labelId="category-select-label"
value={categoryFilter}
label="Category"
onChange={handleCategoryChange}
disabled={loading}
sx={{ borderRadius: 2 }}
>
{categories.map(category => (
<MenuItem key={category} value={category}>
{category === 'all' ? 'All Categories' : category}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid xs={12} sm={6} md={3}>
<FormControl fullWidth>
<InputLabel id="sort-select-label">Sort by</InputLabel>
<Select
labelId="sort-select-label"
value={sortBy}
label="Sort by"
onChange={handleSort}
disabled={loading}
sx={{ borderRadius: 2 }}
>
<MenuItem value="default">Default</MenuItem>
<MenuItem value="priceLow">Price: Low to High</MenuItem>
<MenuItem value="priceHigh">Price: High to Low</MenuItem>
<MenuItem value="name">Name</MenuItem>
<MenuItem value="rating">Rating</MenuItem>
<MenuItem value="newest">Newest</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid xs={12} sm={6} md={3} sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body2">
Showing {filteredAndSortedProducts.length} products
</Typography>
</Grid>
</Grid>
{/* Error message */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{/* Product grid */}
{loading ? (
<Grid
container
spacing={{ xs: 1, sm: 2, md: 3 }}
columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}
>
{Array.from(new Array(itemsPerPage)).map((_, index) => (
<Grid xs={1} key={index}>
<ProductCardSkeleton />
</Grid>
))}
</Grid>
) : filteredAndSortedProducts.length === 0 ? (
<Alert severity="info" sx={{ mb: 2 }}>
No products found matching your criteria.
</Alert>
) : (
<>
<Grid
container
spacing={{ xs: 1, sm: 2, md: 3 }}
columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}
>
{paginatedProducts.map((product) => (
<Grid xs={1} key={product.id}>
<ProductCard
product={product}
onAddToCart={handleAddToCart}
onToggleFavorite={handleToggleFavorite}
isFavorite={favorites.includes(product.id)}
/>
</Grid>
))}
</Grid>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
<Pagination
count={totalPages}
page={currentPage}
onChange={handlePageChange}
color="primary"
size="large"
showFirstButton
showLastButton
/>
</Box>
)}
</>
)}
</Box>
);
};
export default ProductGrid;
This comprehensive implementation includes:
- Advanced filtering, sorting, and pagination
- Loading states with skeleton placeholders
- Favorites and cart functionality
- Responsive layout with different column counts based on screen size
- Accessibility improvements
- Performance optimizations with memoization
- Discount display and price formatting
- Rating display
- Comprehensive error handling
Wrapping Up
In this guide, we've explored MUI Grid v2 in depth and built a production-ready, responsive product grid layout. We've covered the core concepts, advanced features, best practices, and common issues you might encounter when working with Grid v2.
The key takeaways from this guide are:
- Grid v2 is a powerful layout system based on CSS Grid that offers more flexibility than the original Grid component
- It provides built-in responsive capabilities through breakpoint props
- Creating responsive product grids requires thoughtful planning of column counts, spacing, and item layouts
- Performance optimizations are crucial for large product lists
- Accessibility should be a priority in your grid implementations
With the knowledge and code examples provided in this guide, you should now be well-equipped to create beautiful, responsive product grid layouts using MUI Grid v2. Remember that the best layouts are those that prioritize user experience across all devices while maintaining good performance and accessibility.