Help us improve
Share bugs, ideas, or general feedback.
From mui-expert
Optimizes MUI performance in React/Next.js apps via tree-shaking named imports, bundle analysis tools, and Emotion caching for SSR rendering.
npx claudepluginhub markus41/claude --plugin mui-expertHow this skill is triggered — by the user, by Claude, or both
Slash command
/mui-expert:performanceThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use named imports from `@mui/material`. Never import from barrel files or index — bundlers
Provides 70+ React/Next.js performance optimization rules across 8 priority categories (waterfalls, bundle size, server-side, client fetching, re-render, rendering, JS micro-perf, advanced). Use when writing, reviewing, or refactoring React/Next.js code for performance.
Configures MUI server-side rendering in Next.js for App Router, Pages Router, and RSC with Emotion cache and ThemeRegistry components.
Optimizes React performance using React.memo for component memoization, custom prop comparisons, and useMemo for expensive computations like filtering and sorting. Use for preventing unnecessary re-renders.
Share bugs, ideas, or general feedback.
Use named imports from @mui/material. Never import from barrel files or index — bundlers
cannot tree-shake those effectively.
// BAD — imports the entire @mui/material bundle (~300 KB+ gzipped)
import { Button, TextField, Dialog } from '@mui/material';
// GOOD — each import is individually tree-shaken
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import Dialog from '@mui/material/Dialog';
// BAD — imports all 2100+ icons (~1 MB+)
import { Delete, Edit, Add } from '@mui/icons-material';
// GOOD — only the used icon is bundled
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add';
If you must use named imports from barrels, configure the plugin to transform them:
// .babelrc
{
"plugins": [
["babel-plugin-import", {
"libraryName": "@mui/material",
"libraryDirectory": "",
"camel2DashComponentName": false
}]
]
}
# Install source-map-explorer
npm install --save-dev source-map-explorer
# Add to package.json
"scripts": {
"analyze": "source-map-explorer 'build/static/js/*.js'"
}
# For Next.js, use @next/bundle-analyzer
npm install --save-dev @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({});
// Run:
// ANALYZE=true npm run build
# Webpack bundle analyzer (CRA or custom webpack)
npm install --save-dev webpack-bundle-analyzer
# In webpack config:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
plugins: [new BundleAnalyzerPlugin()]
Without caching, Emotion regenerates style sheets on every SSR request. Use createCache
with a CacheProvider for significant SSR performance gains.
// lib/createEmotionCache.ts
import createCache from '@emotion/cache';
export default function createEmotionCache() {
return createCache({ key: 'css', prepend: true });
}
// _app.tsx (Next.js Pages Router)
import { CacheProvider, EmotionCache } from '@emotion/react';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import createEmotionCache from '../lib/createEmotionCache';
const clientSideEmotionCache = createEmotionCache();
interface MyAppProps extends AppProps {
emotionCache?: EmotionCache;
}
export default function MyApp({ Component, emotionCache = clientSideEmotionCache, pageProps }: MyAppProps) {
return (
<CacheProvider value={emotionCache}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</CacheProvider>
);
}
// _document.tsx — inject emotion styles before MUI styles
import Document, { Html, Head, Main, NextScript } from 'next/document';
import createEmotionServer from '@emotion/server/create-instance';
import createEmotionCache from '../lib/createEmotionCache';
export default class MyDocument extends Document {
render() {
return (
<Html lang="en">
<Head>{(this.props as any).emotionStyleTags}</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
MyDocument.getInitialProps = async (ctx) => {
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
const originalRenderPage = ctx.renderPage;
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App: any) => (props) => <App emotionCache={cache} {...props} />,
});
const initialProps = await Document.getInitialProps(ctx);
const emotionStyles = extractCriticalToChunks(initialProps.html);
const emotionStyleTags = emotionStyles.styles.map((style) => (
<style
data-emotion={`${style.key} ${style.ids.join(' ')}`}
key={style.key}
dangerouslySetInnerHTML={{ __html: style.css }}
/>
));
return { ...initialProps, emotionStyleTags };
};
The sx prop creates a new object on every render, causing Emotion to recalculate styles.
import { useMemo } from 'react';
import Box from '@mui/material/Box';
// BAD — new object reference every render triggers style recalculation
function MyComponent({ isActive }: { isActive: boolean }) {
return (
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: isActive ? 'primary.light' : 'grey.100',
}}
>
Content
</Box>
);
}
// GOOD — memoize the sx object when it depends on props/state
function MyComponent({ isActive }: { isActive: boolean }) {
const sx = useMemo(
() => ({
p: 2,
borderRadius: 1,
backgroundColor: isActive ? 'primary.light' : 'grey.100',
}),
[isActive]
);
return <Box sx={sx}>Content</Box>;
}
// BEST for static styles — define outside component (zero recalculation)
const styles = {
container: { p: 2, borderRadius: 1 },
active: { backgroundColor: 'primary.light' },
inactive: { backgroundColor: 'grey.100' },
} as const;
function MyComponent({ isActive }: { isActive: boolean }) {
return (
<Box sx={[styles.container, isActive ? styles.active : styles.inactive]}>
Content
</Box>
);
}
import React, { memo, useCallback } from 'react';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
interface ItemProps {
id: string;
label: string;
onDelete: (id: string) => void;
}
// memo prevents re-render when parent re-renders but props are unchanged
const ProductItem = memo(function ProductItem({ id, label, onDelete }: ItemProps) {
return (
<ListItem
secondaryAction={
<IconButton aria-label={`Delete ${label}`} onClick={() => onDelete(id)}>
<DeleteIcon />
</IconButton>
}
>
<ListItemText primary={label} />
</ListItem>
);
});
// In parent — stabilize callback with useCallback
function ProductList({ items }: { items: Item[] }) {
const handleDelete = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []); // no deps — setItems is stable
return (
<List>
{items.map((item) => (
<ProductItem
key={item.id}
id={item.id}
label={item.name}
onDelete={handleDelete}
/>
))}
</List>
);
}
// BAD — new function reference on every render
<Button onClick={() => handleSave(item.id)}>Save</Button>
// GOOD — stable reference
const handleSave = useCallback(() => {
doSave(item.id);
}, [item.id]);
<Button onClick={handleSave}>Save</Button>
Render only visible rows — critical for DataGrid-like scenarios with 1000+ rows.
// Option 1: MUI X DataGrid (built-in virtualization)
import { DataGrid } from '@mui/x-data-grid/DataGrid';
<DataGrid
rows={largeDataset} // 10,000+ rows — only renders ~20 visible rows
columns={columns}
getRowId={(row) => row.id}
/>
// Option 2: react-window for custom lists
import { FixedSizeList } from 'react-window';
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
function VirtualizedList({ items }: { items: string[] }) {
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => (
<ListItem style={style} key={index} component="div" disablePadding>
<ListItemText primary={items[index]} />
</ListItem>
);
return (
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={46}
width="100%"
>
{Row}
</FixedSizeList>
);
}
// Option 3: react-virtuoso (easier API, variable row heights)
import { Virtuoso } from 'react-virtuoso';
import ListItem from '@mui/material/ListItem';
<Virtuoso
style={{ height: '400px' }}
totalCount={items.length}
itemContent={(index) => (
<ListItem>
<ListItemText primary={items[index].name} />
</ListItem>
)}
/>
MUI X components (DataGrid, DatePicker) are large. Split them to a separate chunk.
import { lazy, Suspense } from 'react';
import CircularProgress from '@mui/material/CircularProgress';
import Box from '@mui/material/Box';
// Lazy load DataGrid — only fetched when component mounts
const DataGrid = lazy(() =>
import('@mui/x-data-grid').then((m) => ({ default: m.DataGrid }))
);
// Lazy load DatePicker
const DatePicker = lazy(() =>
import('@mui/x-date-pickers/DatePicker').then((m) => ({ default: m.DatePicker }))
);
function Loading() {
return (
<Box display="flex" justifyContent="center" p={4}>
<CircularProgress />
</Box>
);
}
function MyPage() {
return (
<Suspense fallback={<Loading />}>
<DataGrid rows={rows} columns={columns} />
</Suspense>
);
}
// Next.js — disable SSR for heavy client-only components
import dynamic from 'next/dynamic';
const RichTextEditor = dynamic(() => import('../components/RichTextEditor'), {
ssr: false,
loading: () => <CircularProgress />,
});
const ChartsSection = dynamic(() => import('../components/ChartsSection'), {
ssr: false,
});
See the Emotion caching section above for full _app.tsx + _document.tsx setup.
npm install @emotion/server @emotion/cache @emotion/react
Key points:
EmotionCache per request (not shared across requests)extractCriticalToChunks in getInitialProps<Head> before the page renders// app/layout.tsx
import ThemeRegistry from '@/components/ThemeRegistry/ThemeRegistry';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeRegistry>{children}</ThemeRegistry>
</body>
</html>
);
}
// components/ThemeRegistry/ThemeRegistry.tsx
'use client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { NextAppDirEmotionCacheProvider } from './EmotionCache';
import theme from '@/lib/theme';
export default function ThemeRegistry({ children }: { children: React.ReactNode }) {
return (
<NextAppDirEmotionCacheProvider options={{ key: 'mui' }}>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</NextAppDirEmotionCacheProvider>
);
}
// components/ThemeRegistry/EmotionCache.tsx
'use client';
import { useServerInsertedHTML } from 'next/navigation';
import { CacheProvider } from '@emotion/react';
import createCache from '@emotion/cache';
import { useState } from 'react';
export function NextAppDirEmotionCacheProvider({
options,
children,
}: {
options: { key: string };
children: React.ReactNode;
}) {
const [{ cache, flush }] = useState(() => {
const cache = createCache(options);
cache.compat = true;
const prevInsert = cache.insert;
let inserted: string[] = [];
cache.insert = (...args) => {
const serialized = args[1];
if (cache.inserted[serialized.name] === undefined) {
inserted.push(serialized.name);
}
return prevInsert(...args);
};
const flush = () => {
const prevInserted = inserted;
inserted = [];
return prevInserted;
};
return { cache, flush };
});
useServerInsertedHTML(() => {
const names = flush();
if (names.length === 0) return null;
let styles = '';
for (const name of names) {
styles += cache.inserted[name];
}
return (
<style
key={cache.key}
data-emotion={`${cache.key} ${names.join(' ')}`}
dangerouslySetInnerHTML={{ __html: styles }}
/>
);
});
return <CacheProvider value={cache}>{children}</CacheProvider>;
}
// Split DataGrid, DatePicker, and Charts into separate chunks
// Each will only load when that route/component is first rendered
// routes/reports.tsx — DataGrid loads only when user visits /reports
const ReportsDataGrid = lazy(() =>
import('@/components/ReportsDataGrid') // ReportsDataGrid imports DataGridPremium internally
);
// routes/analytics.tsx — Charts load only when user visits /analytics
const AnalyticsCharts = lazy(() => import('@/components/AnalyticsCharts'));
// routes/schedule.tsx — DatePicker loads only when user visits /schedule
const SchedulePicker = lazy(() => import('@/components/SchedulePicker'));
// BAD — new theme object on every render causes all consumers to re-render
function App() {
return (
<ThemeProvider theme={createTheme({ palette: { mode: 'dark' } })}>
<App />
</ThemeProvider>
);
}
// GOOD — create once outside of component or in useMemo
const theme = createTheme({
palette: { mode: 'light' },
});
// For dynamic themes (user toggles dark mode):
function App() {
const [mode, setMode] = useState<'light' | 'dark'>('light');
const theme = useMemo(
() => createTheme({ palette: { mode } }),
[mode]
);
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<ToggleButton onClick={() => setMode((m) => (m === 'light' ? 'dark' : 'light'))}>
Toggle theme
</ToggleButton>
<MyApp />
</ThemeProvider>
);
}
@mui/material barrel)@mui/icons-material barrel)sx objects are defined outside componentssx objects that depend on props are wrapped in useMemouseCallback or are defined outside the render