Conditionally load a React component

0 min read

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.

jsx
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:

  1. Add a parent element to the ContactForm component.
  2. Start observing the parent element to know that that section is in the viewport and
  3. Disconnect the observer on unmount to avoid memory leak.
jsx
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.

js
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:

jsx
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:

jsx
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.

Share this post on

Table of Contents