The rise of frameworks like Next.js and CRA have solved a significant part of the performance problem by handling most of the performance optimizations by default. But there are some performance optimizations that cannot be automated as they depend on use cases that cannot be predicted. E.g., Loading a component when your code meets a condition defined by you.
Dynamically Loading components will decrease the size of the bundle that we fetch on page load, hence improving the performance. This concept of loading components dynamically during runtime is called lazy loading.
The Plan
If a user lands on a page a lot of times, they never reach the bottom of the page. If you use a heavy component in those parts, it makes sense to load them when the user reaches there.
In this blog, we will take the following code and modify it to load ContactForm
when the user scrolls to its position.
import ContactForm from "./ContactForm";
export const App = () => (
<div>
{/* Other Components */}
<ContactForm />
</div>
);
We will do this in two parts: know when the user has scrolled to the contact form's location and then load the ContactForm
when that condition is met.
The technique discussed in this blog will work irrespective of the framework (Next.js, CRA, etc.) you are using, as we will depend on pure JS and React APIs to implement this functionality.
Detect Visibility
We will use IntersectionObserver
API to detect when the section containing ContactForm
is in view. If you use a browser that doesn't support
the IntersectionObserver
API, you can use the polyfill instead. This implementation will involve three parts:
- Add a parent element to the
ContactForm
component. - Start observing the parent element to know that that section is in the viewport and
- Disconnect the observer on unmount to avoid memory leak.
import { useRef, useEffect } from "react";
const contactParentRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// Load the `ContactForm` component.
}
});
});
observer.observe(contactParentRef.current); // Observe parent on mount.
return () => observer.disconnect(); // Disconnect observer on unmount
}, []);
export const App = () => (
<div>
{/* Other Components */}
<div ref={contactParentRef}>
{/* Lazily loaded `ContactForm` component */}
</div>
</div>
);
Load the component dynamically
Now we are going to import the ContactForm
component dynamically by using dynamic import()
. Before we move to that, let's understand what dynamic import()
is and why we are using it.
Dynamic import()
The standard import()
syntax that we use is static and will always result in all code in the imported module being evaluated at load time.
The dynamic import()
is different as it only imports the module when you call import()
at runtime.
async function loadModuleDynamically() {
const module = await import("module-name");
}
In the above example, the request for the JS bundle containing module-name
is only made when you call loadModuleDynamically
. Since we want to load a module dynamically once the parent node is in view, we will use dynamic import()
to load the ContactForm
component once the user scrolls to that position.
Using import
to load ContactForm
Now that we understand what dynamic import()
is, we will use it to load the ContactForm
component when the parent element comes in the viewport. We will store the component in the current
property of a ref called LazyComponent
:
import { useRef, useEffect } from "react";
const contactParentRef = useRef(null);
const LazyComponent = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting) {
LazyComponent.current = (
await import("./ContactForm")
).default;
}
});
});
observer.observe(contactParentRef.current); // Observe parent on mount.
return () => observer.disconnect(); // Disconnect observer on unmount
}, []);
export const App = () => (
<div>
{/* Other Components */}
<div ref={contactParentRef}>
<LazyComponent.current />
</div>
</div>
);
The reason we need .default
is that since webpack 4, when
importing a CommonJS module, the import no longer resolves to the
value of module.exports
, it instead creates an artificial
namespace object for the CommonJS module. For more information on
the reason behind this, read webpack 4: import()
and
CommonJs.
We aren't done yet since you will not see the loaded component in view. The reason is that mutating a ref object's .current
property doesn't cause a re-render. In order to view the loaded contact form, we will have to re-render the component.
We will use a separate state variable and change it's value after the ContactForm
component is loaded, hence triggering a re-render:
import { useRef, useEffect, useState } from "react";
const contactParentRef = useRef(null);
const LazyComponent = useRef(null);
const [isContactFormLoaded, setIsContactFormLoaded] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting) {
LazyComponent.current = (
await import("./ContactForm")
).default;
setIsContactFormLoaded(true);
// We do not need to observe the element once the component has loaded
observer.disconnect();
}
});
});
observer.observe(contactParentRef.current); // Observe parent on mount.
return () => observer.disconnect(); // Disconnect observer on unmount
}, []);
export const App = () => (
<div>
{/* Other Components */}
<div ref={contactParentRef}>
{isContactFormLoaded && <LazyComponent.current />}
</div>
</div>
);
Notice that LazyComponent.current
, the ref's name in which we
store the dynamically loaded component, is capitalized. We have to
do this because we are using that as a custom React component. It is
necessary that user-defined react components are
capitalized.
Conclusion
The introduction of dynamic import()
has opened new ways to optimize the performance of our applications without relying on the APIs provided by the framework. We can use it to decrease the size of the main bundle and improve the Time To Interactivity (TTI) of our webpage.