performance

v2026.09.25

MUI performance optimization — tree-shaking, bundle size, rendering, SSR

GitHub
Install command
npx skhub add thelobbi/performance
Markdown
SKILL.md

MUI Performance Optimization

Tree-Shaking — Named Imports Only

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';

Icons — always deep import

// 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';

babel-plugin-import (alternative for barrel imports)

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
    }]
  ]
}

Bundle Analysis

# 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()]

Emotion Caching

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 };
};

Avoiding Re-renders

Memoize sx objects

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>
  );
}

Memoize components

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>
  );
}

Avoid inline function handlers in render

// 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>

Virtualization for Large Lists

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>
  )}
/>

Lazy Loading Heavy Components

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>
  );
}

Lazy load entire feature sections

// 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,
});

SSR Setup

Next.js Pages Router

See the Emotion caching section above for full _app.tsx + _document.tsx setup.

npm install @emotion/server @emotion/cache @emotion/react

Key points:

  • Create a fresh EmotionCache per request (not shared across requests)
  • Extract critical CSS via extractCriticalToChunks in getInitialProps
  • Inject style tags in <Head> before the page renders

Next.js App Router (v13+)

// 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>;
}

Code Splitting for MUI X

// 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'));

Theme Singleton (Avoid Recreation)

// 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>
  );
}

Performance Checklist

  • All MUI imports are deep imports (not from @mui/material barrel)
  • All icon imports are deep imports (not from @mui/icons-material barrel)
  • Static sx objects are defined outside components
  • Dynamic sx objects that depend on props are wrapped in useMemo
  • Event handlers in lists use useCallback or are defined outside the render
  • List components with 100+ items use virtualization
  • DataGrid, DatePicker, Charts are lazy-loaded in routes that don't need them on initial load
  • Emotion cache is per-request on SSR (not a singleton)
  • Theme is created once (not inside render)
  • Bundle analyzer run after major dependency changes
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.25

Published

Sep 25, 2026

Category

Uncategorized

License

MIT

Source path

plugins/mui-expert/skills/performance

Default branch

main

Latest commit

2f1269c

Tree SHA

629e050