How I optimised icon loading in Storybook and Vite


The Problem

For the purposes of this blog post the Storybook in question is a React based component library using Vite as the Storybook builder. It is unfortunately closed source, but I will provide some working code based on it in this blog post. The icons are each their own React components along the lines of:

// @filename: icons/LightBulb.tsx
import React from "react";
import function IconBase(props: IconProps): React.JSX.ElementIconBase, { type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps
} from "../IconBase";
export default function function LightBulbIcon(props: IconProps): React.JSX.ElementLightBulbIcon(props: IconPropsprops: type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps
) {
return ( <function IconBase(props: IconProps): React.JSX.ElementIconBase> <JSX.IntrinsicElements.path: React.SVGProps<SVGPathElement>path React.SVGAttributes<SVGPathElement>.d?: string | undefinedd="..."></JSX.IntrinsicElements.path: React.SVGProps<SVGPathElement>path> </function IconBase(props: IconProps): React.JSX.ElementIconBase> ); }

All of the 200+ icons are then re-exported from a single index file like so:

// @filename: index.tsx
export { default as LightBulbIcon } from "./icons/LightBulb";
// ...

I am aware there are better ways of doing icons in React land, this project is a little special in that React is never run on the client, but that is beside the point. The problem here is with the way Vite works in a dev environment, it does not bundle source code, it does an HTTP request for every import and runs esbuild on each file to compile away TypeScript and JSX. This means that if we use a single icon from the index file it makes 200+ HTTP requests, which as you can imagine is a lot of overhead and very slow. So I started looking for solutions.

Initial findings

The first place I looked was the Vite docs, as I find them to be a great example of documentation and very easy to read. I quickly came across “Dependency Pre-Bundling” which sounded like exactly what I need. It even speaks about the exact issue I am having “Some packages ship their ES modules builds as many separate files importing one another.”. So I started messing around with the optimizeDeps option, trying to get it to bundle my index file. This proved difficult, I could not get it to bundle the file no matter how I formatted the paths. It then occurred to me that all the docs on this subject are talking about node_modules or linked dependencies. So I did the next logical step and set up an alias to trick Vite into thinking it was a node_module, this still didn’t seem to work, I can only assume Vite is very aware of what is source code and what isn’t. So I had to come up with something else.

A three-pronged approach

After getting on with some other work, I thought about how I might solve this issue in the background. I eventually settled on a three-pronged attack on icons across the board.

One: Direct imports

Components that consume icons as a one-off can simply import the raw file, not the index. This means it will only load what it needs for each of these components.

Two: Lazy-loaded modules

For stories that have a control for the currently shown icon, we will dynamically import each of the icons, so they aren’t requested until selected. This was surprisingly simple with Vite’s import.meta.glob API and the React.lazy API. It looks something like this:

import React from "react";
import function IconBase(props: IconProps): React.JSX.ElementIconBase from "./IconBase";

const const icons: Record<string, () => Promise<{
default: typeof IconBase;
}>>
icons
= import.meta.ImportMeta.glob: ImportGlobFunction
<{
default: typeof IconBase;
}>(glob: string | string[], options?: ImportGlobOptions<false, string> | undefined) => Record<string, () => Promise<{
default: typeof IconBase;
}>> (+2 overloads)
glob
<{ default: (props: IconProps) => React.JSX.Elementdefault: typeof function IconBase(props: IconProps): React.JSX.ElementIconBase }>("./icons/*.tsx");
export default function function useIcon(iconName: string | undefined): React.LazyExoticComponent<typeof IconBase> | undefineduseIcon( iconName: string | undefinediconName: string | undefined ): React.type React.LazyExoticComponent<T extends React.ComponentType<any>> = React.ExoticComponent<React.CustomComponentPropsWithRef<T>> & {
readonly _result: T;
}
LazyExoticComponent
<typeof function IconBase(props: IconProps): React.JSX.ElementIconBase> | undefined {
if (iconName: string | undefinediconName) { const const iconPath: stringiconPath = function getIconPathFromName(iconName: string): stringgetIconPathFromName(iconName: stringiconName); const const iconModule: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>
iconModule
= const icons: Record<string, () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>>
icons
[const iconPath: stringiconPath];
if (const iconModule: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>
iconModule
) {
return React.function React.lazy<(props: IconProps) => React.JSX.Element>(factory: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>): React.LazyExoticComponent<(props: IconProps) => React.JSX.Element>
lazy
(const iconModule: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>
iconModule
);
} } } export const const iconNames: string[]iconNames = var Object: ObjectConstructorObject.ObjectConstructor.keys(o: {}): string[] (+1 overload)keys(const icons: Record<string, () => Promise<{
default: typeof IconBase;
}>>
icons
);

This gives us a hook that returns a lazy component or undefined given an icon name. We can then use it in our stories file:

// @filename: Icon.stories.tsx
import React from "react";
import function IconBase(props: IconProps): React.JSX.ElementIconBase, { type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps
} from "./IconBase";
import function useIcon(iconName: string | undefined): React.LazyExoticComponent<typeof IconBase> | undefineduseIcon, { const iconNames: string[]iconNames } from "./useIcon"; import type { type StoryFn<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.AnnotatedStoryFn<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.AnnotatedStoryFn<...>StoryFn, type Meta<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.ComponentAnnotations<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.ComponentAnnotations<...>Meta } from "@storybook/react"; export default { ComponentAnnotations<ReactRenderer, Args>.title?: string | undefinedtitle: "Icon", ComponentAnnotations<ReactRenderer, Args>.component?: React.ComponentType<any> | undefinedcomponent: function IconBase(props: IconProps): React.JSX.ElementIconBase, argTypes?: Partial<ArgTypes<Args>> | undefinedargTypes: { icon: {
options: string[];
control: {
type: "select";
};
}
icon
: {
InputType.options?: readonly any[] | undefinedoptions: const iconNames: string[]iconNames, InputType.control?: Control | undefinedcontrol: { type: "select"type: "select", }, }, }, } as type Meta<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.ComponentAnnotations<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.ComponentAnnotations<...>Meta; const const Template: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Template
: type StoryFn<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.AnnotatedStoryFn<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.AnnotatedStoryFn<...>StoryFn<type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps
& { icon?: string | undefinedicon?: string }> = (args: IconProps & {
icon?: string | undefined;
}
args
) => {
const { const icon: string | undefinedicon, ...const props: {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
props
} = args: IconProps & {
icon?: string | undefined;
}
args
;
const const Icon: React.LazyExoticComponent<(props: IconProps) => React.JSX.Element> | undefinedIcon = function useIcon(iconName: string | undefined): React.LazyExoticComponent<(props: IconProps) => React.JSX.Element> | undefineduseIcon(const icon: string | undefinedicon); if (!const Icon: React.LazyExoticComponent<(props: IconProps) => React.JSX.Element> | undefinedIcon) { return <></>; } return <const Icon: React.LazyExoticComponent<(props: IconProps) => React.JSX.Element>Icon {...const props: {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
props
} />;
}; export const const Default: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Default
= const Template: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Template
.CallableFunction.bind<AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>>(this: AnnotatedStoryFn<ReactRenderer, IconProps & {
...;
}>, thisArg: unknown): AnnotatedStoryFn<...> (+1 overload)
bind
({});
const Default: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Default
.args?: Partial<IconProps & {
icon?: string | undefined;
}> | undefined
args
= {
icon?: string | undefinedicon: "LightBulbIcon", };

Now when a user selects an icon name from the icon control in the Storybook interface, React will dynamically import the appropriate icon module and render it when it is available. Meaning only the default selected icon is loaded at runtime.

Three: All Icon Reference

For stories or documentation that show all the icons next to each other for reference, we still have the original issue. Since my previous attempts failed, I decided to go down a level of abstraction and write a plugin (at this point I just wanted things to work). Thankfully, Vite uses a plugin architecture borrowed from Rollup, which I have had a fair amount of experience writing in the past. Furthermore, we know it uses esbuild to transpile source code on the fly, so why not just do that for our index file. Below is roughly what the plugin looks like:

import { interface Plugin<A = any>Plugin } from "vite";
import { function build<T extends BuildOptions>(options: SameShape<BuildOptions, T>): Promise<BuildResult<T>>build } from "esbuild";
import * as const path: PlatformPathpath from "path";

export default function function bundleIconsPlugin(): PluginbundleIconsPlugin(): interface Plugin<A = any>Plugin {
  let let command: stringcommand: string;
  return {
    OutputPlugin.name: stringname: "bundle-icons",
    Plugin<any>.config?: ObjectHook<(this: void, config: UserConfig, env: ConfigEnv) => void | Omit<UserConfig, "plugins"> | Promise<void | Omit<UserConfig, "plugins"> | null> | null> | undefinedconfig(config: UserConfigconfig, { ConfigEnv.command: "build" | "serve"command: _command: "build" | "serve"_command }) {
      let command: stringcommand = _command: "build" | "serve"_command;
    },
    async Plugin<any>.load?: ObjectHook<(this: PluginContext, id: string, options?: {
ssr?: boolean | undefined;
} | undefined) => LoadResult | Promise<LoadResult>> | undefined
load
(id: stringid: string) {
// Check we are serving, as this is a development mode optimisation. // Also check we are loading the index file. if (let command: stringcommand === "serve" && id: stringid.String.endsWith(searchString: string, endPosition?: number | undefined): booleanendsWith("/Icon/index.tsx")) { const { const outputFiles: OutputFile[]outputFiles } = await build<{
entryPoints: string[];
bundle: true;
platform: "browser";
write: false;
jsx: "preserve";
absWorkingDir: string;
format: "esm";
plugins: {
name: string;
setup(build: PluginBuild): void;
}[];
}>(options: SameShape<...>): Promise<...>
build
({
entryPoints: string[]entryPoints: [id: stringid], bundle: truebundle: true, platform: "browser"platform: "browser", write: falsewrite: false, jsx: "preserve"jsx: "preserve", absWorkingDir: stringabsWorkingDir: var process: NodeJS.Processprocess.NodeJS.Process.cwd(): stringcwd(), format: "esm"format: "esm", // We also need a custom esbuild plugin because we want to treat everything as // external apart from our individual icon files. plugins: {
name: string;
setup(build: PluginBuild): void;
}[]
plugins
: [
{ name: stringname: "externals", function setup(build: PluginBuild): voidsetup(build: PluginBuildbuild) { build: PluginBuildbuild.PluginBuild.onResolve(options: OnResolveOptions, callback: (args: OnResolveArgs) => OnResolveResult | Promise<OnResolveResult | null | undefined> | null | undefined): voidonResolve({ OnResolveOptions.namespace?: string | undefinednamespace: "file", OnResolveOptions.filter: RegExpfilter: /.*/ }, (args: OnResolveArgsargs) => { if (args: OnResolveArgsargs.OnResolveArgs.kind: ImportKindkind === "entry-point") { return null; } // If the file is in our icons folder use standard resolution. if (args: OnResolveArgsargs.OnResolveArgs.path: stringpath.String.startsWith(searchString: string, position?: number | undefined): booleanstartsWith("./icons/")) { return null; } else { // Otherwise flag is external. return { OnResolveResult.path?: string | undefinedpath: const path: PlatformPathpath.path.PlatformPath.join(...paths: string[]): stringjoin(args: OnResolveArgsargs.OnResolveArgs.resolveDir: stringresolveDir, args: OnResolveArgsargs.OnResolveArgs.path: stringpath), OnResolveResult.external?: boolean | undefinedexternal: true, }; } }); }, }, ], }); // Make sure we build something. if (!const outputFiles: OutputFile[]outputFiles || const outputFiles: OutputFile[]outputFiles.Array<T>.length: numberlength !== 1) { return null; } // Return the bundled file contents. return const outputFiles: OutputFile[]outputFiles[0].OutputFile.text: stringtext as string; } return null; }, }; }

Conclusion

And that is it. Three approaches that work in tandem to make Storybook DX so much nicer with large icon sets! I hope this blog post inspired you or solved a problem you were having, either way, thanks for reading.