Dynamically Importing React Material Icons Using Webpack
The sample code for this post is available on my GitHub.
One of my team members was working on our app's navigation and for the icons we are using the material-ui-icons package. This is an npm package that provides the Material UI icons as React components. We're also using webpack for bundling and babel for transpiling.
Their question was around how to dynamically reference these icon components within the navigation component. We dynamically build navigation from a JSON structure and need to load the appropriate icon for each item.
Imagine something like this to represent some settings menu:
{
"name": "Settings",
"children": [
{
"name": "Manage Alarms",
"icon": "AccessAlarmIcon"
}
]
}
Somehow we need to import the appropriate icon from material-ui-icons
and reference it based on the icon
parameter:
const MaterialIcon = ({ icon }) => {
// somehow resolve this icon
let resolvedIcon = // ...
return React.createElement(resolvedIcon)
}
const Navigation = (props) =>
<nav>
{props.children.map(item =>
<li>
<MaterialIcon icon={item.icon} />
{item.name}
</li>
)}
</nav>
The initial solution was to just hardcode the logic:
import AccessAlarmIcon from 'material-ui-icons/AccessAlarm'
const MaterialIcon = ({ icon }) => {
switch (icon) {
case 'AccessAlarmIcon': return <AccessAlarmIcon />
default: return null
}
}
But we knew this was a sub-optimal solution, it requires updating this file each time we want to support a new icon. There must be a better way!
Referencing the scope
At first, we tried referencing AccessAlarmIcon
imported variable directly since it should be in the scope of the module.
import AccessAlarmIcon from 'material-ui-icons/AccessAlarm'
const MaterialIcon = ({ icon }) => {
return React.createElement(eval(icon))
}
Yes, eval
is evil but we wanted to see if it worked before trying a safer solution. It didn't work.
Uncaught ReferenceError: AccessAlarmIcon is not defined
Hmm, it should be, right? Wrong. Webpack is doing some bundling magic for us. The compiled code looks like this:
var _AccessAlarm = __webpack_require__(70);
var _AccessAlarm2 = _interopRequireDefault(_AccessAlarm);
So first, it's not even named AccessAlarmIcon
at runtime and second, we have some webpack magic going on with variable naming and such.
Using the Webpack API
I decided to look into this over the weekend since it was an interesting problem. After playing around for a bit, I decided to look and see what webpack could offer us via its API.
It turns out we can leverage require
to just use a convention-based method of dynamically importing the icons. Within a webpack context, it will handle resolving the dependency for us internally.
const MaterialIcon = ({ icon }) => {
let iconName = icon.replace(/Icon$/, '')
let resolved = require(`material-ui-icons/${iconName}`).default
if (!resolved) {
throw Error(`Could not find material-ui-icons/${iconName}`)
}
return React.createElement(resolved)
}
Eyyy, look at that!
Turned out to be a simple, straightforward solution but it wasn't immediately apparent.
Making it async
You could also asynchronously load the icon using import()
which returns a native Promise
object. To make that work with React, we can use react-async-component and create an async factory component:
import { asyncComponent } from 'react-async-component'
const MaterialIconAsync = ({ icon }) => {
let iconName = icon.replace(/Icon$/, '')
return React.createElement(asyncComponent({
resolve: () => import(
/* webpackMode: "eager" */
`material-ui-icons/${iconName}`)
}))
}
I am using the eager fetch mode to include all the Material icons vs. performing network requests to fetch them.
Note: When using Babel with the new dynamic import
syntax, you'll need to install syntax-dynamic-import preset and enable it.