Tree-Shaking Icon Libraries and Large UI Kits

Symptom you will see: a component that renders a single trash-can icon —

import { FaTrash } from 'react-icons/fa';

export function DeleteButton() {
  return <button><FaTrash /> Delete</button>;
}

— and the production build’s analyzer report shows react-icons contributing over 1,800 KB uncompressed to the chunk that contains this one component. The Network tab confirms it: a single icon renders on screen, but the JavaScript payload for that route is dominated by an icon set you never asked for in full.

Module: node_modules/react-icons/fa/index.esm.js
Size: 1,842 KB
Exports used: 1 of 941 (FaTrash)

The same shape of problem appears with @mui/icons-material (thousands of per-icon files behind one package) and with any large component kit where a single import { Button } from 'ui-kit' line quietly pulls in the whole design system.

Icon libraries are a worse case than most utility libraries because the ratio of unused-to-used exports is far more extreme. A typical application might use 15–30 distinct icons out of a set of 1,000–2,000, meaning 97–98% of the imported module is dead weight — compared to a utility library like lodash, where a handful of used methods out of ~300 still leaves a “merely” 90–95% dead-weight ratio. That extra order of magnitude is why icon barrels routinely top the list in bundle analyzer reports even on codebases that have already addressed their lodash and date library imports.

Root cause: barrel re-exports at package scale

This is the icon-and-UI-kit variant of the retention problem covered by the optimizing lodash and utility library imports cluster, and it overlaps closely with the general barrel file bloat problem — icon packages are simply the most extreme real-world example of a barrel file, because a set like react-icons/fa aggregates nearly a thousand individual icon components behind one generated index.esm.js.

react-icons/fa is technically an ES module with real export statements for every icon, so in theory static analysis should be able to prune the 940 icons you don’t use. In practice, several factors defeat that pruning:

  • The barrel file is machine-generated and extremely large (900+ export statements in one file), which some bundler configurations handle conservatively rather than risk an incorrect elimination.
  • Each icon component in the set shares a common internal GenIcon helper and an SVG path-data object. If the bundler’s sideEffects analysis cannot prove that helper has no observable side effects, it retains the whole dependency chain defensively.
  • Import paths like react-icons/fa resolve to the package-wide barrel rather than an individual icon file, so even a perfectly tree-shaking-capable bundler configuration still has to load and parse 1,800 KB of source before it can determine which 2 KB is actually reachable.

The @mui/icons-material package structures itself differently — every icon genuinely is its own file (DeleteIcon.js, EditIcon.js, and so on) rather than one aggregated barrel — but a default import from the package root (import { DeleteIcon } from '@mui/icons-material') still resolves through an aggregating index.js that re-exports thousands of icon modules, reproducing the same retention risk through a different mechanism. lucide-react, by contrast, ships ESM-first with no root barrel in its distributed build, which is why it tree-shakes reliably without any extra plugin in most configurations.

Exact fix per library

react-icons — per-icon import paths

The most reliable fix bypasses the category barrel entirely and imports the icon’s own file:

// Before — resolves to the fa barrel, ~1,800 KB of exports parsed
import { FaTrash } from 'react-icons/fa';

// After — resolves directly to the FaTrash icon module
import FaTrash from 'react-icons/fa/FaTrash.js';

Not every version of react-icons exposes stable per-icon file paths, so verify the installed version’s package structure with ls node_modules/react-icons/fa/ before committing to this pattern across a codebase. Where per-icon paths are unavailable, babel-plugin-import automates the equivalent rewrite for barrel-style destructured imports:

// .babelrc — Webpack 5 / Vite 5+ (via a Babel-enabled plugin pipeline)
{
  "plugins": [
    ["import", {
      "libraryName": "react-icons/fa",
      "libraryDirectory": "",
      "camel2DashComponentName": false
    }, "react-icons-fa"]
  ]
}

This rewrites import { FaTrash } from 'react-icons/fa' into a direct file import automatically, matching the per-icon file naming convention babel-plugin-import is configured to expect for that specific icon set.

@mui/icons-material — modularizeImports

Next.js exposes a built-in modularizeImports transform that rewrites @mui/icons-material named imports into per-icon paths without a manual Babel plugin:

// next.config.js — Next.js on Webpack 5 or Turbopack
module.exports = {
  modularizeImports: {
    '@mui/icons-material': {
      transform: '@mui/icons-material/{{member}}',
    },
  },
};

For a non-Next.js Webpack 5 or Vite setup, babel-plugin-import covers the same transform:

// .babelrc — Webpack 5 / Vite 5+
{
  "plugins": [
    ["import", {
      "libraryName": "@mui/icons-material",
      "libraryDirectory": "",
      "camel2DashComponentName": false
    }, "mui-icons"]
  ]
}

Or skip the plugin entirely and import each icon from its own path directly, which is more verbose but requires no build configuration:

// Explicit per-icon import — works with zero plugin configuration
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';

The full @mui/material component kit benefits from the same modularizeImports treatment, since import { Button, TextField } from '@mui/material' resolves through an aggregating barrel identically to the icon package:

// next.config.js — cover the full MUI component kit, not just icons
module.exports = {
  modularizeImports: {
    '@mui/material': {
      transform: '@mui/material/{{member}}',
    },
    '@mui/icons-material': {
      transform: '@mui/icons-material/{{member}}',
    },
  },
};

lucide-react — verify, don’t assume

lucide-react’s current release line ships one ES module file per icon with no aggregating barrel in the distributed build, so this pattern already tree-shakes correctly:

// Already tree-shakes correctly — no plugin needed on current lucide-react
import { Trash2, Pencil } from 'lucide-react';

Confirm this holds for your installed version by checking the analyzer report after adding a single icon import — if lucide-react shows as a multi-hundred-KB block rather than a handful of KB, the installed version or a re-export wrapper somewhere in the codebase is defeating the package’s own ESM-first design; check for a local icons.ts barrel that re-exports a large subset of the library, which reintroduces the exact problem at the application layer instead of the package layer.

Vite optimizeDeps tuning

Vite’s dev server pre-bundles dependencies with esbuild for fast cold starts, which is a separate concern from production tree-shaking but frequently gets confused with it. A large icon package can slow dev server startup noticeably even when the production build tree-shakes it correctly:

// vite.config.ts — Vite 5+
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    // Pre-bundle the icon packages actually used across many entry points
    include: ['lucide-react', '@mui/icons-material'],
    // Exclude a package you've already switched to fully per-path imports for —
    // pre-bundling it as one unit can reintroduce the aggregation esbuild is meant to avoid
    exclude: ['react-icons'],
  },
});

optimizeDeps only affects the development server; it has no effect on the production vite build output, where Rollup’s own tree-shaking pass is what determines the final bundle contents. Do not treat a fast dev server as confirmation that the production build is also optimized — always verify with a production build and analyzer report, as covered in reading webpack-bundle-analyzer treemaps and its Vite equivalent, setting up rollup-plugin-visualizer in Vite.

Step-by-step verification checklist

  1. Generate a bundle analyzer report before changing anything. Note the exact retained size for the icon or UI kit package in question.
  2. Apply the per-library fix — per-icon paths, babel-plugin-import, or modularizeImports — to one component first.
  3. Rebuild and confirm the module count for that package drops from one large aggregated block to individual small files, one per icon or component actually imported.
  4. Search for local re-export barrels (grep -rn "export.*from.*react-icons\|lucide-react\|@mui/icons-material" src/) that might reintroduce aggregation at the application layer even after the library-level fix.
  5. Measure the delta with size-limit --compare and confirm it falls in the 90%+ range for icon libraries specifically — icon sets have an unusually high ratio of unused-to-used exports compared to general utility libraries.
  6. Re-run the dev server and confirm optimizeDeps changes did not introduce a duplicate-instance warning for the same package.
  7. Add a CI budget scoped to a component that uses icons, so a future contributor reintroducing a barrel-style import fails the build rather than shipping.

Edge cases and gotchas

Gotcha 1 — icon font fallbacks reintroduce the whole set

Some design systems fall back to a bundled icon font (.woff2 plus a CSS class map) alongside the React icon components, “just in case.” If the font file ships all glyphs regardless of which icon components are used in JavaScript, the tree-shaking fix on the JS side has no effect on this separate, and often larger, asset. Audit font-based icon delivery independently — it is not something a bundler’s tree-shaking pass touches at all.

Gotcha 2 — dynamic icon name lookups defeat every fix in this guide

A pattern like const Icon = icons[iconName]; return <Icon /> where icons is an object built from a barrel import forces the bundler to retain every icon in that object, because it cannot statically determine which key iconName will hold at runtime. This mirrors the dynamic-lookup anti-patterns discussed in the broader advanced tree-shaking and dependency optimization pillar — any indirection through a runtime-computed key defeats static analysis regardless of how cleanly the underlying package tree-shakes. The fix is the same allow-list approach: build an explicit mapping of the specific icon names your application actually renders, rather than spreading an entire icon set into a lookup object.

Gotcha 3 — a UI kit’s CSS-in-JS runtime ships regardless of which components tree-shake

Component kits that use a runtime CSS-in-JS engine (rather than build-time extraction) often bundle the styling engine itself as an unavoidable side-effectful dependency, separate from the tree-shakeable component modules. Even a perfectly tree-shaken import list for @mui/material still pays the fixed cost of Emotion or a similar styling runtime — verify this fixed cost separately using the analyzer report so it isn’t mistaken for a failed tree-shaking fix when a component-only reduction leaves it untouched.


FAQ

Why does importing one icon from react-icons add over a megabyte to my bundle?

Each react-icons subpackage such as react-icons/fa is a barrel file that re-exports every icon in that set as a named export from one generated module. Importing even a single named export forces the bundler to parse and often retain the entire barrel unless the bundler’s tree-shaking can prove the other exports are unused, which large auto-generated barrels frequently defeat.

Does @mui/icons-material tree-shake by default in Vite 5+?

Each icon in @mui/icons-material is published as its own ES module file, so a named import like import { DeleteIcon } from '@mui/icons-material' can tree-shake correctly under Rollup’s default settings. In practice it still under-performs if the import goes through the package’s root barrel index rather than the per-icon path, so explicit per-icon imports or the modularizeImports Babel transform remain the more reliable fix.

Is lucide-react tree-shakeable out of the box?

Yes, for the current ESM-first release line. lucide-react ships one file per icon with real export statements and no CommonJS barrel in its build output, so named imports resolve to individual icon files without any additional plugin. Older major versions and forks that still ship a single aggregated file do not share this property.