Lazy Loading React Components Without Sacrificing Type Safety

Learn how to lazy load React components while maintaining strong type safety using a simple utility function.

#react
#javascript
#optimization
Lazy Loading React Components Without Sacrificing Type Safety
Picture by Elena Loshina

Over the years, the React community has found many creative ways to dynamically import components and improve a React app's performance by lazy-loading them on demand. This has led to the creation of many well-known libraries, but sometimes, adding a new library to the codebase implies maintaining version updates and potential breaking changes.

Once upon a time... React.lazy() and Suspense

In 2018, the React core team introduced the React.lazy() function that allows, combined with the <Suspense /> component, to enable code-splitting in your app without relying on external libraries. This was a great addition, and every React developer is familiar with the following snippet.

tsx
const LazyComponent = React.lazy(() => import('./component'));

function MyApp() {
return (
<Suspense fallback={<span>Loading...</span>}>
<LazyComponent />
</Suspense>
);
}

This was a great addition to the community, although our expectations grew with the adoption of this new feature and, as a devs community, we demanded more features.

Does this work with Typescript?

React.lazy() provides out-of-the-box support for component type inference.

tsx
const LazyComponent = React.lazy(() => import('./component'));
// TS definition on hover
// LazyComponent: React.LazyExoticComponent<(props: LazyComponentProps) => React.JSX.Element>

Now, there is no issue at all with this code, and it's probably one of the most common approaches taken when it comes to lazy-load components.

However, while working on a big codebase that requires precise code-splitting to keep performance high, we might find ourselves writing a lot of times something like this:

tsx
// index.ts

const ComponentOneLazy = lazy(() => import('./component_one'));

export function ComponentOne(props: ComponentOneProps) {
return (
<Suspense fallback={null}>
<ComponentOneLazy {...props} />
</Suspense>
);
}

const ComponentTwoLazy = lazy(() => import('./component_two'));

export function ComponentTwo(props: ComponentTwoProps) {
return (
<Suspense fallback={null}>
<ComponentTwoLazy {...props} />
</Suspense>
);
}

const ComponentThreeLazy = lazy(() => import('./component_three'));

export function ComponentThree(props: ComponentThreePageProps) {
return (
<Suspense fallback={null}>
<ComponentThreeLazy {...props} />
</Suspense>
);
}

// And so on...

This can get easily out of hand and you'll find your self having to every time wrap your component with a Suspense component, explicitly passing props type that could be automatically inferred, and just making everything less readable.

The dynamic utility solution

I call it dynamic inspired by the next/dynamic utility function, but since not every codebase is based on the Next.js framework, having a similar utility available in your codebase can be achieved quite easily.

It is a basic implementation, but it will take care of many aspects of a component lazy-loading:

  • Ensure the dynamic import passes through the React.lazy() utility.
  • The lazy imported component is wrapped with <Suspense />
  • The component properties are automatically inferred using Typescript generics.
  • The imported component can still receive a forwarded ref.
  • We can define a fallback component to use while loading the async code chunk.
tsx
import React, { Suspense } from 'react';

type Loader<TElement extends React.ComponentType<any>> = () => Promise<{
/* Force a default module export type */
default: TElement;
}>;

/**
* Options for the lazy loaded component
*/
export interface DynamicOptions {
/* Fallback UI element to use when loading the component */
fallback?: React.SuspenseProps['fallback'];
}

/**
* Lazy load and wrap with Suspense any component.
*
* @example
* // Lazy load a component
* const Header = dynamic(() => import('./components/header'))
* // Lazy load a component and use a fallback component while loading
* const Header = dynamic(() => import('./components/header'), {fallback: <EuiLoadingSpinner />})
* // Lazy load a named exported component
* const MobileHeader = dynamic<MobileHeaderProps>(() => import('./components/header').then(mod => ({default: mod.MobileHeader})))
*/
export function dynamic<TElement extends React.ComponentType<any>, TRef = {}>(
loader: Loader<TElement>,
options: DynamicOptions = {}
) {
const Component = React.lazy(loader);

return React.forwardRef<TRef, React.ComponentPropsWithRef<TElement>>((props, ref) => (
<Suspense fallback={options.fallback ?? null}>
{React.createElement(Component, { ...props, ref })}
</Suspense>
));
}

Breaking down the above implementation:

  • type Loader defines what we expect as an argument, which should be a dynamic import call.
  • interface DynamicOptions defines the accepted optional parameters, currently only the fallback item to display while loading.
  • The generic definition of dynamic inferring a React.ComponentType type from the passed loader function.
  • React.lazy() is then used to dynamically import the lazy component.
  • A new component is returned that wraps with <Suspense /> (and applies the fallback if passed) the original lazily loaded component. I used React.createElement() instead of a JSX instantiation to explicitly pass the ref property along to the others and not incur in typing issues.
  • Use React.ComponentPropsWithRef<TElement> to extract the properties type from the imported component.

The final result allows us to use the dynamic utils anywhere in our codebase, always retrieving strongly typed components and applying all the React lazy loading requirements.

tsx
// index.ts

export const ComponentOne = dynamic(() => import('./component_one'));

export const ComponentTwo = dynamic(() => import('./component_two'), {
fallback: <div>Loading...</div>
});

export const ComponentThree = dynamic(() =>
import('./component_three').then(mod => ({ default: mod.ComponentThree }))
);

I implemented this utility function for the Open Source Kibana codebase, and it's been a great experience using it so far!

Last updated: