Introduction
The icon picker dialog control has been a challenging component to implement in our project. This article chronicles the various hurdles encountered and the solutions explored. From initial task breakdown to final component integration, we’ll delve into the intricacies of creating a functional and efficient icon picker.
Throughout this article, we’ll discuss key aspects such as:
- Task Management: How the initial tasks were identified and organized for effective development.
- Component Structure: Challenges related to DOM nesting and the decision to use a singleton pattern.
- Code Optimization: Techniques for code splitting and lazy loading to improve performance.
- Portals: The implementation of portals for isolating the icon picker dialog.
- Icon Loading: Strategies for efficiently loading and managing the large number of icons.
- Integration: The integration of the icon picker into the main application.
By the end of this article, you’ll have a better understanding of the complexities involved in creating an icon picker dialog control and the potential solutions that can be applied to similar projects.

Microsoft Designer: Image Creator Prompt by Google Gemini
Imagine a computer monitor displaying a partially open icon picker dialog box, surrounded by a clutter of code snippets and diagrams. This scene encapsulates the challenges and triumphs of building an icon picker control.
Use a color palette of blues and greens to evoke a sense of technology and progress. Choose a clean, sans-serif font for a modern and professional look. Consider adding symbolic elements like gears, cogs, or light bulbs to represent the problem-solving and innovation involved in the development process. The icon picker dialog itself could be a prominent feature.
The overall mood should convey a sense of determination, perseverance, and eventual success.
To streamline the icon picker development process, I’ve created 15 GitHub issues to capture the outstanding tasks that we discussed yesterday.
Feature Slice
The icon assets are stored in the @/features directory. Redux integration for a dedicated icon slice is still pending.
DOM Nesting & Portals
I encountered a DOM nesting issue when attempting to place a small icon within a button to trigger the icon picker dialog. To maintain proper component structure and ensure the dialog appears only once (as a singleton), I needed to move the dialog outside of the button. While the Provider method seemed initially promising, I hesitated due to potential global namespace pollution and the desire for lazy loading.
To address these concerns, I turned to portals. These are essentially isolated React components that can be rendered into a specific DOM node, separate from the main application tree. Since I didn’t have a suitable node available, I created one at the bottom of the page and managed its reference for subsequent renders. Here’s a code snippet demonstrating this approach:
const id = 'icon-picker-portal-container';
const container = useRef<HTMLElement | null>(null);
useEffect(() => {
if (container.current) return;
container.current = document.getElementById(id);
if (container.current) return;
container.current = document.createElement('div');
container.current.id = id;
document.body.appendChild(container.current);
}, []);
if (container.current === null) return null;
return createPortal(<IconPickerDialog />, container.current);
Observing the top-level structure, I notice that the MUI Dialog component behaves similarly to our existing elements, appearing outside of the React root when displayed.

I’ve revisited the issue and found that the content I expected to be in my portal is instead appearing within the MUI Dialog. This indicates that the original DOM nesting problem might not be caused by the dialog’s placement.
page.bundle.js:6 Warning: validateDOMNesting(...): <button> cannot appear as a descendant of <button>.
at button
at .../chunk-7M6Y5ARF.js?...:2322:46
at ButtonBase2 (.../chunk-G46DU2IU.js?...:570:17)
at .../chunk-7M6Y5ARF.js?...:2322:46
at IconButton2 (.../@mui_material_IconButton.js?...:188:17)
at button
at .../chunk-7M6Y5ARF.js?...:2322:46
at ButtonBase2 (.../chunk-G46DU2IU.js?...:570:17)
at .../chunk-7M6Y5ARF.js?...:2322:46
at Button2 (.../@mui_material_Button.js?...:369:31)
at div
at .../chunk-7M6Y5ARF.js?...:2322:46
at Grid2 (.../chunk-R7LI3DXA.js?...:3612:24)
at div
at .../chunk-7M6Y5ARF.js?...:2322:46
at Grid3 (.../chunk-R7LI3DXA.js?...:3307:19)
at div
at .../chunk-7M6Y5ARF.js?...:2322:46
at Grid3 (.../chunk-R7LI3DXA.js?...:3307:19)
at div
at .../chunk-7M6Y5ARF.js?...:2322:46
at Paper2 (.../chunk-UCFOBZ3M.js?...:100:17)
at IconPickerIcons (.../src/features/icons/IconPickerIcons.tsx?t=1728575548147:30:3)
...
The error messages are a bit difficult to decipher. It seems the issue might lie within the IconPickerIcons component itself. Perhaps renaming it to IconPickerPage or simply Page would be more appropriate now that it’s in its own feature.
And it turns out I’ve made a classic mistake! I have an icon button directly nested within another button, both of which are rendered as HTML <button /> elements. My intention was to use the default text color for the icon and the secondary color for the button border.
<>
<Button
color="secondary"
variant={isSelected ? "contained" : "outlined"}>
<IconButton>
{
(() => {
const IconComponent = Icons[icon.value as MuiIcons];
return <IconComponent fontSize="large" />
})()
}
</IconButton>
</Button>
<Typography
noWrap
align="center"
variant="caption"
>
{icon.label}
</Typography>
</>
To resolve the issue, I removed the <IconButton> component and set the color="action" attribute on the <IconComponent>. This change caused the entire button to have a ripple effect, instead of just a small circle within it. With this fix in place, the portal is no longer needed.
Code Splitting
To optimize performance, let’s split the code so that the icon picker functionality is only loaded when needed. This is especially important considering the size of the MUI Icons library, which contains over 10,000 SVG icons. We’ll begin by creating an index file to load the icon picker.
import { lazy } from "react";
export default lazy(
() => import(/* webpackChunkName: 'icon-picker' */ "./IconPicker")
);
It seems that Vite doesn’t handle webpack chunk names for dynamic imports in the same way. Instead of icon-picker.tsx, I’m seeing requests for IconPicker.tsx. Additionally, while the icon picker is being lazy loaded, it’s still being loaded even when not displayed. To address this, I’ll need to use a wrapper to prevent the majority of the content from loading until the picker is actually opened.
The wrapper worked well, and the dialog now slides up smoothly. However, I encountered a new issue: the dialog doesn’t slide out of view when closed. Instead, it disappears entirely because I’m preventing it from rendering once it’s closed. I’ll need to find a way to keep it rendering, even in the closed state, after it’s initially loaded.
const [loaded, setLoaded] = useState(false);
useEffect(() => {
if (open && !loaded) setLoaded(true);
}, [open]);
if (!loaded) return null;
return <IconDialogLazy open={open} />
That’s a great solution. The dialog transition now works smoothly. While the project is progressing well, I’m concerned about the large MUI icons library and its SVG images. Loading the entire library for a single icon is inefficient. Typically, icons are hardcoded, allowing developers to import only the necessary ones. However, my website is designed to let users choose their own icons after the code is compiled.
// Recommended
import MoveToInbox from "@mui/icons-material/MoveToInbox";
// Not Recommended
import * as Icons from '@mui/icons-material';
export const Control = () => {
return <>
<MoveToInbox />
<Icons.MoveToInbox />
<>
}
I’m facing a challenge. I want users to have control over the icons displayed on the site, but I don’t want to load all the icons for every user. I need to implement code splitting for the MUI Icons themselves. It seems like the MUI Icons library should already support this, given that we can load individual icons in our projects or use dynamic imports. Here’s the code I’m trying to use:
import ErrorBoundary from '@/components/ErrorBoundary';
import { IconProps } from '@mui/material/Icon';
import Skeleton from '@mui/material/Skeleton';
import { FC, lazy, Suspense } from 'react';
type Props = { name?: string } & IconProps;
export const Icon: FC<Props> = ({ name, ...rest }) => {
if (name === undefined) return null;
const LoadableIcon = lazy(
() => import(`@mui/icons-material/${name}`)
);
return (
<ErrorBoundary fallback="Error">
<Suspense fallback={<Skeleton />}>
<LoadableIcon {...rest} />
</Suspense>
</ErrorBoundary>
)
}
export default Icon;
The code seems mostly correct. I’ve added a loading state and error handling for icons that might not exist if the library changes. However, it’s not loading, and Vite is displaying an error on the command line, indicating that it can’t analyze the import @mui/icons-material/${name}.
The above dynamic import cannot be analyzed by Vite.
See https://github.com/rollup/plugins/tree/master/packages/dynamic-import-vars#limitations for supported dynamic import formats. If this is intended to be left as-is, you can use the /* @vite-ignore */ comment inside the import() call to suppress this warning.
The issue lies with the Rollup bundler itself. There are numerous rules to follow for dynamic imports to work correctly. You can’t reference third-party libraries directly; all paths must be relative. Additionally, you need to specify the file extension, and the entire file name can’t be dynamic. Some pattern matching is required.
I considered a workaround: removing the first letter of the icon name and using it as a pattern for 26 file prefixes. For relative paths, I traversed up from the /src/ folder and down into the /node_modules/ folder to locate the files. I found them in both .../icons-material/Abc.js (CommonJS) and .../icons-material/esm/Abc.js (ESM). Unfortunately, this didn’t work. Even statically importing them directly from the node_modules folder didn’t succeed. According to the package.json, the /esm/ route should have worked.
{
"exports": {
".": {
"types": "./index.d.ts",
"import": "./esm/index.js",
"require": "./index.js"
},
"./*": {
"types": "./*.d.ts",
"import": "./esm/*.js",
"require": "./*.js"
},
"./esm/*": "./esm/*.js",
"./esm/*.js": "./esm/*.js"
}
}
I’m consistently encountering the following error:
caught SyntaxError: The requested module ‘/node_modules/prop-types/index.js?v=9417074b’ does not provide an export named ‘default’ (at elementAcceptingRef.js?v=9417074b:1:8)
Both the CommonJS and ESM files have default exports. To force dynamic loading, I considered a brute-force approach: specifying static paths for all icons. This resulted in a massive 10,630 lines of generated code. While not ideal, here’s the code for generating the lazy loader for all 10,616 icons. As a precaution, I added the file to .gitignore to prevent it from being included in the repository and updated the project’s build and bundle scripts to generate the icon loader. To avoid accidentally committing the generated file, I renamed it with a generated- prefix and updated .gitignore to ignore files with that prefix.
import fs from 'fs';
const filePath = 'src/features/icons/IconPicker/generated-loadIcon.ts';
fs.writeFileSync(filePath,
`/** GENERATED FILE: DO NOT MODIFY
*
* npm run generate-loadable-mui-icons
*/
import { IconProps } from '@mui/material/Icon';
import { ComponentType, lazy } from 'react';
type LoadableIcon = Promise<{ default: ComponentType<IconProps> }>;
export const loadIcon = (name: string) => lazy(async (): LoadableIcon => {
switch (name) {`, 'utf8');
fs.readdirSync('node_modules/@mui/icons-material')
.filter(file =>
/\.js$/.test(file) && !/^index\./.test(file)
).map(file => file.replace(/\.js$/, ''))
.sort()
.forEach(name => {
fs.appendFileSync(filePath, `
case '${name}':
return import('@mui/icons-material/${name}') as LoadableIcon;`,
'utf8');
});
fs.appendFileSync(filePath, `
default: throw new Error(\`Unable to load \${name}\`);
}
});`, 'utf8');
Vite struggled to load the loadIcon file, causing a noticeable delay. It seemed to attempt to display a warning, but after listing every icon import, the original message was truncated due to a limited terminal buffer. The warning was followed by optimized dependencies changed. reloading. When displaying a list of icons, I’m seeing numerous chunks load at once. Vite is occasionally showing Pre-transform errors after hot module reloads, specifically related to the icon loader:
3:55:56 PM [vite] hmr update /src/features/icons/IconPicker/Icon.tsx
3:55:56 PM [vite] Pre-transform error: Transform failed with 1 error:
/Users/lewismoten/dev/inventory-system/src/features/icons/IconPicker/generated-loadIcon.ts:7290:91: ERROR: Unexpected end of file
Vite seems to have stabilized now. I believe the initial issue was related to regenerating files, renaming the file, and the first-time encounter with such a process before caching could be established.
A positive outcome is that slow connections now allow for individual icon loading, with each icon ranging from one to three kilobytes. However, the large number of requests is a concern. An 8×4 grid results in 32 requests, and hidden pages add another 64. To optimize performance, I’m considering delaying the loading of hidden pages until the initially visible page has made its initial requests.

To reduce the number of requests, I’m considering grouping them together. If I had usage statistics, I could optimize the grouping. For now, I’ll group them in batches. Even if you need only one icon, you’ll load 10 icons, including the desired one. This might seem inefficient, but it will significantly reduce requests when viewing pages of icons in the icon manager, which are listed alphabetically. To further optimize, I could ensure that filtered icons are grouped together. For example, if I need a rounded icon, I wouldn’t load 9 other non-rounded icons, as the likelihood of needing a similar style in subsequent requests is higher.
Our goal is to initially categorize the icons by style and then group them alphabetically to optimize loading. This will allow multiple icons to be loaded in a single request.
const styles = icons.reduce((styles, name) => {
const style = [
'Outlined',
'Rounded',
'Sharp',
'TwoTone'
]
.find(style => name.endsWith(style)) ?? 'Filled';
styles[style].push(name);
return styles;
}, {
Outlined: [],
Rounded: [],
Sharp: [],
TwoTone: [],
Filled: []
});;
Object.keys(styles)
.forEach(
key => styles[key].sort()
);
The sorting is straightforward and doesn’t address edge cases. While I’m not concerned with edge cases or the sorting of parsed names, I need a solution to reduce the number of requests. The main challenge lies in determining how to load the batches. I don’t believe I can dynamically import files simultaneously. A separate file might be necessary. Since we’re working with promises and asynchronous requests, let’s try a Promise.all approach first. Here’s what I’m considering:
export const loadIcon = (name: string) => lazy(async (): LoadableIcon => {
switch (name) {
case 'Abc':
case 'AcUnit':
case 'AccessAlarm':
return Object.fromEntries(
await Promise.all([
import('@mui/icons-material/Abc')
.then(m => ['Abc', m]),
import('@mui/icons-material/AcUnit')
.then(m => ['AcUnit', m]),
import('@mui/icons-material/AccessAlarm')
.then(m => ['AccessAlarm', m])
])
)[name]
The generated file has now grown to 21,916 lines of code. Despite this, I’m still observing multiple requests, including groups of requests. Here’s an example of a request for the DownloadForOffline icon.
"use client";
import {
DownloadForOffline_default
} from "/node_modules/.vite/deps/chunk-UZKVPA5D.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-C6WWHQR7.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-YCFGSRIM.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-XI3DBMRW.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-LFF3PLC3.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-HAQXWPNW.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-MVYEGH2S.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-RPV2RTQC.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-SFM437KJ.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-GHQNXBMH.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-DUFGSTVO.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-JU3RP5ZY.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-ULUQSVLK.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-F2ZX6LCW.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-QDY2E52N.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-WH5OLFXP.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-ANI7LW2M.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-S725DACQ.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-RLJ2RCJQ.js?v=c8528e18";
import "/node_modules/.vite/deps/chunk-DC5AMYBS.js?v=c8528e18";
export {
DownloadForOffline_default as default
};
//# sourceMappingURL=@mui_icons-material_DownloadForOffline.js.map
The code is currently importing 20 icons and then returning the specific one for DownloadForOffline. Instead, it should evaluate the passed-in name to return the correct value. I’m unsure why it’s importing 20 files instead of 32. It seems I’ll need to generate individual files.
I initially generated files in batches of 32. To further optimize, I separated them by style. The icon loader now loads by style and then by batch.
import { IconProps } from '@mui/material/Icon';
import { ComponentType, lazy } from 'react';
type Icon = ComponentType<IconProps>;
type LoadableIcon = Promise<{ default: Icon }>;
export const loadIcon = (name: string) =>
lazy(async (): LoadableIcon => {
const style = [
'Outlined',
'TwoTone',
'Rounded',
'Sharp'
]
.find(style => name.endsWith(style));
let imported: Promise<{ resolveIcon: (name: string) => Promise<Icon> }>;
switch (style) {
case 'Outlined':
imported = import('./MuiIcon/generated-Rounded.ts');
break;
case 'TwoToned':
imported = import('./MuiIcon/generated-TwoTone.ts');
break;
case 'Rounded':
imported = import('./MuiIcon/generated-Outlined.ts');
break;
case 'Sharp':
imported = import('./MuiIcon/generated-Sharp.ts');
break;
default:
imported = import('./MuiIcon/generated-Filled.ts');
break;
}
const iconStyle = await imported;
const Icon = await iconStyle.resolveIcon(name);
return { "default": Icon };
});
From now on, each icon style will load its corresponding batches of icons.
import { ComponentType } from 'react';
import { IconProps } from '@mui/material/Icon';
import { OverridableComponent } from '@mui/material/OverridableComponent';
import { SvgIconTypeMap } from '@mui/material/SvgIcon';
type Icon = ComponentType<IconProps>;
export const resolveIcon = async (name: string): Promise<Icon> => {
let module: Record<string, OverridableComponent<SvgIconTypeMap>> = {};
switch (name) {
case 'AbcOutlined':
case 'AcUnitOutlined':
case 'AccessAlarmOutlined':
// ...
module = await import('./Outlined/generated-0.ts');
return module[name] as Icon;
case 'AddModeratorOutlined':
case 'AddOutlined':
// ...
default: throw new Error(`Unable to load ${name}`);
}
};
The batch files are quite simple.
import AbcTwoTone from '@mui/icons-material/AbcTwoTone';
import AcUnitTwoTone from '@mui/icons-material/AcUnitTwoTone';
// ...
export {
AbcTwoTone,
AcUnitTwoTone,
// ...
}
Has it had any impact? Not in development mode. I just realized that Vite prefers to load everything individually for hot module replacement and requires a mechanism for replacing individual files. I’ll need to observe how it behaves in production.
Running vite --mode production doesn’t seem to affect how files are loaded. vite preview starts a server but only responds with 404 errors. Let’s try building the project instead. The files look good. I realized I haven’t deployed the React version since the changeover. After updating the build scripts, I had to transfer 356 files, primarily generated files. These files ranged from 3-20 KB, while the main style files were 46-64 KB. Gzip can significantly compress those larger files, reducing them to 13 KB. However, I’m still not satisfied with their size.
Publishing to production… or staging, whichever it is, seems to be working as expected. However, there are too many files being requested at once. I anticipated one or two files, but it’s loading many. Scrolling down to load the next page, 13 files were accessed.
I believe the issue lies in the user interface grouping icons by category. If there were no groups, we’d only need one page of alphabetical icons for the selected fill style. Should I divide the generator files? No, the icon manager can change those groups dynamically. Alphabetical ordering seems like the best solution for now.
I’m concerned about the large number of files being downloaded at once and the fact that unnecessary icons are being loaded. Is it possible to send an array of icon names to the PHP server and receive a single JavaScript file containing all of them? That would be ideal, allowing for on-demand loading of multiple icons in a single request while also tracking which items have already been loaded.
That’s essentially what we need for optimal loading. Let’s examine how some of the files are loaded. If possible, it might be better to code-split all icon files and load them generically through a custom API.
I believe I’ve found a different approach. These icons are primarily SVG paths. We could create an API endpoint to return the icon SVG paths directly. This eliminates the need for dynamic component loading. Let’s start by retrieving the SVG path for TakeoutDining.
M5.26 11h13.48l-.67 9H5.93zm3.76-7h5.95L19 7.38l1.59-1.59L22 7.21 19.21 10H4.79L2 7.21 3.41 5.8 5 7.38z
Is it possible to render this icon using an <SvgIcon> from MUI, given that I have it stored in memory?
<SvgIcon><svg><path d={svg} /></svg></SvgIcon>

If I can use SVGs everywhere, I can serve those icons on-demand and leverage RTK Query for caching. This will reduce the size of my codebase and generated files. I can request one or many icons. Let’s explore the steps involved in fixing the button format and integrating the data into the API.
It turns out MUI provides a helper utility for creating SVG icons. Using this method, the icon appears as its original self.
const svg = "M5.26 11h13.48l-.67 9H5.93zm3.76-7h5.95L19 7.38l1.59-1.59L22 7.21 19.21 10H4.79L2 7.21 3.41 5.8 5 7.38z";
const Foo = createSvgIcon(
<path d={svg} />,
"TakeoutDining"
);
return <Foo
color="action"
fontSize="large"
/>;
That’s a great solution. Now I just need to extract the SVG paths for ad-hoc queries.
Even though my project is set up as a module, I’m encountering errors when trying to load @mui/material-icons using normal Node scripts with imports. It suggests renaming the file with an .mjs extension, but that doesn’t work.
Examining the source files, I noticed varying paths. Some have multiple paths, and others have an extra parameter before the icon name. I realized that using CommonJS with a .cjs extension works. Now I can rely on the components to render the output…
const Icons = require('@mui/icons-material/index');
console.log(
Icons.Abc.type.render()
.props.children.props
);
// output
{
d: 'M21 11h-1.5v-.5h-2v3h2V13H21v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1zM8 10v5H6.5v-1.5h-2V15H3v-5c0-.55.45-1 1-1h3c.55 0 1 .45 1 1m-1.5.5h-2V12h2zm7 1.5c.55 0 1 .45 1 1v1c0 .55-.45 1-1 1h-4V9h4c.55 0 1 .45 1 1v1c0 .55-.45 1-1 1M11 10.5v.75h2v-.75zm2 2.25h-2v.75h2z'
}
Not all icons are created equally. I’m observing various elements like path, circle, ellipse, g, and React Fragments. Some icons are standalone, while others are returned as arrays. Once I’ve parsed all of this, I’ll need to serialize the data for transmission and then deserialize it back into an icon. Is this more effort than it’s worth?
Is it possible to render these components within a Node script and capture the output? Essentially, I’m looking to perform server-side rendering.
const Icons = require('@mui/icons-material/index');
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const element = React.createElement(Icons.Abc);
html = ReactDOMServer.renderToString(element);
console.log(html);
<style data-emotion="css 20bmp1-MuiSvgIcon-root">
.css-20bmp1-MuiSvgIcon-root{
-webkit-user-select:none;
-moz-user-select:none;
-ms-user-select:none;
user-select:none;
width:1em;
height:1em;
display:inline-block;
-webkit-flex-shrink:0;
-ms-flex-negative:0;
flex-shrink:0;
-webkit-transition:fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
transition:fill 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
fill:currentColor;
font-size:1.5rem;
}
</style>
<svg
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium css-20bmp1-MuiSvgIcon-root"
focusable="false"
aria-hidden="true"
viewBox="0 0 24 24"
data-testid="AbcIcon">
<path d="M21 11h-1.5v-.5h-2v3h2V13H21v1c0 .55-.45 1-1 1h-3c-.55 0-1-.45-1-1v-4c0-.55.45-1 1-1h3c.55 0 1 .45 1 1zM8 10v5H6.5v-1.5h-2V15H3v-5c0-.55.45-1 1-1h3c.55 0 1 .45 1 1m-1.5.5h-2V12h2zm7 1.5c.55 0 1 .45 1 1v1c0 .55-.45 1-1 1h-4V9h4c.55 0 1 .45 1 1v1c0 .55-.45 1-1 1M11 10.5v.75h2v-.75zm2 2.25h-2v.75h2z"></path>
</svg>
There we go! While we might not need all the style data, I can separate it from the icon and add it to the DOM root if necessary. After reviewing each style, I found they were all identical, so I can parse the first one and discard the rest.
Now, let’s focus on the SVG itself. Using createSvgIcon might add redundant attributes to the SVG tag. I believe we only need the inner content of the <svg> tags. Let’s parse all SVG tags and check if they’re consistent. Yes, all SVG tags are the same except for the data-testid attribute. We can remove the opening and closing SVG tags.
At this point, I have 3.6 megabytes of name/value pairs with icon names and SVG markup. I’ll update the database to store this data and create seed scripts for population.
Before proceeding, I want to consider that the SVG paths are static data. They won’t change, and the database doesn’t need to query them. They aren’t searchable.
I could store the data as a static file and let PHP parse it to serve specific content as needed. However, this would require a 4MB memory allocation for each request that needs an icon. A cache manager could mitigate this by keeping the data available for subsequent requests. Unfortunately, I don’t have Redis on this shared host. While both BlueHost and Hostinger are shared hosts and lack Redis, they do have an older version of Memcached. Security isn’t a major concern as it’s open-source, but other clients on the shared server could potentially access and manipulate the data if they guess or discover the keys. I previously purchased a virtual private server on Contabo, which might be a future solution.
For now, let’s publish the data as a static JSON file and parse it in PHP. Since I’m the only user at the moment, I can split the JSON into five styles to reduce the memory required for parsing, unless someone requests an icon for each style. I’ll set up an API to fetch the paths. Later, I can explore using a cache manager. As long as the API remains unchanged, the client won’t be affected. This approach should provide a quick solution without the overhead of restructuring the database and setting up seed scripts.
The largest file is now 913 kilobytes. There aren’t many significant opportunities for further size reduction. While I could parse out SVG content with only a d attribute, the savings would be minimal. One option is to remove the style suffix from icon names, like changing AbcTwoTone to Abc. With 2,000 entries, this could save around 14 kilobytes, or 1-2%. However, this isn’t enough to justify losing the context of the original icon name when reviewing the file. I need a more substantial way to reduce the file size.

The API is now set up to select icons with pagination. I copied the set-icons-list.php page and added code to load the SVG values before returning the data.
function append_svg(&$rows)
{
if (count($rows) === 0) {
return;
}
foreach ($rows as &$row) {
$setId = $row['setId'];
$value = $row['value'];
$style = get_mui_style($value);
$svg = load_mui_icon_data($setId, $style, $value);
$row['svg'] = $svg;
}
}
function get_mui_style($value)
{
$styles = ['Outlined', 'Rounded', 'Sharp', 'TwoTone'];
foreach ($styles as $style) {
if (str_ends_with($value, $style)) {
return $style;
}
}
return 'Filled';
}
function load_mui_icon_data($setId, $style, $value)
{
global $muiCache;
if (isset($muiCache[$style])) {
return $muiCache[$style][$value];
}
$path = __DIR__ . "/$setId/generated-$style.json";
$json = file_get_contents($path);
if ($json === false) {
throw new Exception("Unable to read file: $path");
}
$obj = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception("Unable to parse JSON: " . json_last_error_msg());
}
$muiCache[$style] = $obj;
return $muiCache[$style][$value];
}
This saves me the hassle of passing every desired icon as a query string when the query string parameters are sufficient.
Upon further reflection, I realize I’m overcomplicating things. Why make a separate request to fetch icons? Anyone viewing the list wants to see the icons. I’ve decided to return the icons along with the original icon search. The API for fetching SVGs should only accept the icons you’re filtering for and return only that data without any metadata.
Now, we need to revisit our apiSlice, specifically the iconApi endpoints, and allow paths to be passed as an optional string. Since I can’t edit SVG content, the endpoints for adding/editing icons don’t require updates, but the structure of the icon interface does.
I’ve integrated the API and passed the paths from the icon grid to the icon component. However, I’ve encountered a challenge. My SVG content is a string, and since this is runtime code, I can’t evaluate the path as a JSX component to pass into the <SvgIcon> component. We’re now working with raw HTML and might need to resort to the dangerouslySetInnerHTML method. While parsing the SVG into HTML elements is an option, it seems excessive.
return <SvgIcon color="action" fontSize="large">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="evenodd"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</SvgIcon>
My initial attempt failed. The browser appears to be interpreting my code as setting both children and inner HTML.
caught Error: Can only set one of
childrenorprops.dangerouslySetInnerHTML.

I’m making progress! I decided to parse the SVG string into React components using regular expressions to identify simple patterns like <path d="...."> or arrays of similar elements. Most icons are based on these patterns. Now we need to focus on ellipses, circles, and ‘g’ elements.
I’ve changed my parsing approach. SVGs don’t have nested tags, which simplifies parsing significantly. For simplicity, I’m parsing all attributes and logging any SVG data that seems invalid. This will help me reject invalid data in the future. It’s a basic approach, but here’s where I am with the validation process:
const parseSvg = (svg: string) => {
const elements = Array.from(svg.matchAll(
/<([^ ]+)([^>]*)><\/\1>/g
));
return elements.map(([_all, name, attributes]) => {
const props = Array.from(attributes.matchAll(
/([\w-]+)\s*=\s*("([^"]*)")/g
))
.reduce((all, [_string, name, _quoted, value]) => {
name = name.replaceAll(
/-[a-z]/g,
s => s.replace('-', '').toUpperCase()
);
all[name] = value;
return all;
}, {} as Record<string, string>);
const propNames = Object.keys(props);
switch (name) {
case 'path':
propNames.forEach(name => {
if (!['d', 'opacity', 'transform', 'fillOpacity', 'fillRule'].includes(name)) {
console.log(`unknown attribute: <path ${name}`)
}
})
return <path {...props} />;
case 'circle':
propNames.forEach(name => {
if (!['cx', 'cy', 'r'].includes(name)) {
console.log(`unknown attribute: <circle ${name}`)
}
})
return <circle {...props} />;
case 'ellipse':
propNames.forEach(name => {
if (!['cx', 'cy', 'rx', 'ry'].includes(name)) {
console.log(`unknown attribute: <ellipse ${name}`)
}
})
return <circle {...props} />;
default:
console.log('unknown tag', name, attributes)
return null;
}
});
}
I’m reviewing all icons across different tabs to identify any new attributes. However, my scripts are currently throttling user requests to 100 per minute, which limits my ability to quickly scroll through the icons.

I spoke too soon about nesting. It turns out the <g> tag (group) has nested components. My regular expression matched everything within the tag but excluded the tag itself. The groups are only used for five icons: BuildCircleOutlined, FactCheckOutlined, AdminPanelSettingsOutlined, MedicationLiquidTwoTone, and RecordVoiceOverTwoTone. Additionally, I just noticed a <defs> tag for reusable elements. I’m getting closer to resolving all icon issues.
I modified the code to handle nesting, but encountered a problem when trying to add a child to a component created without children. It seems the component cannot be extended.
caught TypeError: Cannot add property children, object is not extensible
It seems I’ll need to implement recursion, parsing the inner portion of the element and calling the same method to fetch child components instead of adding them one by one after component creation.
Now, let’s investigate what a DOMParser is. I’ve never heard of it before.
That worked! I wrapped the SVG content in an <svg> tag and parsed it using the DOMParser. From there, I was able to traverse the hierarchy of various elements.
It was a challenging process, but I’ve managed to put everything together, albeit with the bare minimum. I’ve renamed the icon to ParsedSvgIcon. It no longer loads icons by name. It expects only the inner content of an SVG image, parses the information, and converts it to React. MUI then wraps it with its own styling.
import ErrorBoundary from '@/components/ErrorBoundary';
import SvgIcon from "@mui/material/SvgIcon";
import { FC, ReactNode } from 'react';
export const ParsedSvgIcon: FC<{ svg?: string }> =
({ svg }) => {
if (svg === undefined) return "No SVG";
return (
<ErrorBoundary fallback="Error">
<SvgIcon color="action" fontSize="large">
{parseSvg(svg)}
</SvgIcon>
</ErrorBoundary>
)
}
const parseSvg = (text: string): ReactNode => {
let keyIndex = 0;
const parser = new DOMParser();
const doc = parser.parseFromString(
`<svg xmlns="http://www.w3.org/2000/svg">${text}</svg>`,
"text/html"
);
const parseElement = (element: null | Element): ReactNode => {
if (element === null) return null;
const props = Object.fromEntries(
element.getAttributeNames()
.map(name => [
name.replace(/-([a-z])/g,
(_, char) => char.toUpperCase()
),
element.getAttribute(name)
])
);
const key = (++keyIndex).toString();
switch (element.tagName) {
case 'svg':
return parseChildren(element);
case 'path':
return <path key={key} {...props} />
case 'circle':
return <circle key={key} {...props} />
case 'ellipse':
return <ellipse key={key} {...props} />
case 'g':
return <g
key={key}
{...props}
children={parseChildren(element)}
/>
case 'defs':
return <defs
key={key}
{...props}
children={parseChildren(element)}
/>
default:
console.log('Unknown tag', element.tagName);
return null;
}
}
const parseChildren = (element: Element): ReactNode =>
Array.from(element.childNodes)
.map(child => {
switch (child.nodeType) {
case Node.ELEMENT_NODE:
return parseElement(child as Element);
case Node.TEXT_NODE:
return child.textContent;
default:
return null;
}
})
.filter(Boolean);
return parseElement(doc.body.firstChild as Element);
}
export default ParsedSvgIcon;
And… that’s it.
In conclusion, this journey through the icon picker development process has been both challenging and rewarding. We’ve overcome obstacles related to DOM nesting, singleton behavior, code splitting, portals, and performance optimization. By leveraging React’s powerful features and carefully considering design patterns, we’ve created a robust and efficient icon picker component.
While the final solution might not be the most straightforward, it addresses the specific requirements of our application and provides a flexible foundation for future enhancements. The lessons learned from this process can be applied to other development projects, highlighting the importance of careful planning, problem-solving, and a willingness to explore different approaches.
