Performance and modularity are important when building modern React applications. One powerful technique to improve both is dynamic importing of external modules. In this article, we’ll look at how to implement dynamic imports using a custom React hook that gives you control over the loading and error states.
This approach is particularly useful when dealing with large external modules, third-party libraries, or remote components that you want to load only when needed.
Why Use Dynamic Imports?
When working on JavaScript projects, modules are often statically imported at the top of a file:
import MyComponent from './MyComponent';
This works fine for small apps, and these modules are bundled into one file (using bundlers such as Webpack or Vite). But in larger ones, it can often lead to large bundle sizes and slow load times. Enter dynamic imports:
const module = await import('./MyComponent');
The import()
function returns a promise, allowing you to load modules asynchronously. This enables code splitting — breaking your app into smaller chunks that load on demand.
Dynamic imports are especially useful when working with external modules such as large utility libraries or specialized functionality. If you don’t use it, you don’t load it, saving data for you and your users!
Creating a Custom Hook for Dynamic Imports
We could use React.lazy
and Suspense
, but we’re not going to do that here. Instead, we’re going to create a reusable hook that handles the dynamic import of an external module and exposes a clean API with loading and error states.
For this hook, we’re going to apply useState
and useEffect
hooks from React. Here’s the basic structure of our custom hook:
import React, { useState, useEffect } from 'react';
const useImportedModule = (modulePath) => {
const [module, setModule] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const importModule = async () => {
try {
const importedModule = await import(modulePath);
setModule(importedModule);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
importModule();
}, [modulePath]);
return { module, isLoading, error };
};
export default useImportedModule;
Let’s break this hook down:
- Accepts a
modulePath
argument so it can be reused across different external modules - Uses useState to track whether the module is still loading (
isLoading
), any errors during import (error
), and the loaded module (module
) - Returns the above values so your component can react accordingly
Using the Hook in a Component
Now let’s see how to use this hook inside a React component. Here’s an example of a formatted phone number component where we conditionally render a fallback UI while waiting for an imaginary phone formatter module to load.
import React from 'react';
import useImportedModule from './useImportedModule';
const FormattedPhoneNumber= ({ number }) => {
const { module, isLoading, error } = useImportedModule('phone-formatter');
if (isLoading) {
return <div>Loading module...</div>;
}
if (error) {
return <div>Failed to load module: {error.message}</div>;
}
// Assume the module exports a format utility.
const { format } = module;
return <div>{ format(number) }</div>;
};
export default FormattedPhoneNumber;
In this component, we can see that we return a loading message when the module is loading, an error message if something goes wrong, and the actual component once it’s available.
You can now dynamically load any external module and handle its lifecycle directly in your component logic!
Caching Imported Modules
We can improve our hook to cache modules globally or within the hook itself to avoid re-importing the same module multiple times. Let’s see how to implement this:
const moduleCache = {};
const useImportedModule = (modulePath) => {
const [module, setModule] = useState(moduleCache[modulePath] || null);
const [isLoading, setIsLoading] = useState(!moduleCache[modulePath]);
const [error, setError] = useState(null);
useEffect(() => {
if (moduleCache[modulePath]) return;
const importModule = async () => {
try {
const importedModule = await import(modulePath);
moduleCache[modulePath] = importedModule;
setModule(importedModule);
} catch (err) {
setError(err);
} finally {
setIsLoading(false);
}
};
importModule();
}, [modulePath]);
return { module, isLoading, error };
};
We can see the new global moduleCache, which holds our imported modules. If the module exists in the cache, we return the module directly. If not, we’ll do our normal import routine and cache the results once finished.
Caution!
While this hook is a great way to dynamically import modules into your project, there are a few things to consider:
- Use sparingly. While dynamic imports are great for performance, overusing them can fragment your codebase, make debugging complicated, and actually make your app worse.
- Bundle wisely. Tools like Webpack and Vite handle dynamic imports well, but always test how your bundles behave in production.
- Avoid blocking rendering. Always show a meaningful fallback while the module is loading to not leave the user hanging.
Import Into Your Project
Using a custom React hook to manage the dynamic import of external modules can give you fine-grained control over loading states, error handling, and caching. By implementing a solution like useImportedModule
, you can ensure your application remains performant and modular while providing a great user experience.
Try using this hook in your project today!