DEV Community

tmns
tmns

Posted on • Updated on

Code-splitting in React WITHOUT Suspense: useLazyLoad

tl;smtc (too long; show me the code)

You can see a (very contrived) demo and check the hook's code via the below StackBlitz embed. Though, I also suggest opening it in a separate window and observing how the code loads dynamically via your browser's network panel.

Note, I've added a hardcoded delay in the hook to exaggerate the loading time. This is because StackBlitz runs the server in a service worker, so the request always happens instantaneously and can't be easily throttled via e.g. the network panel. You should of course remove it when actually using it in your own project.

Okay, what's this all about?

Recently I found myself implementing a new feature with the following characteristics:

  • The core piece was a multi-step form, with each step containing complex state and a whole lotta functionality (in other words: a hefty chunk of code).
  • This form was then to be presented to the user via a modal.
  • The modal would be triggered via a button interaction.
  • The kicker: it typically wouldn't be used very often (at least by the majority of users).

A whole bunch of code that most users would never use? Sounds like an ideal case for code-splitting. Naturally, the first approach I considered was React's lazy and Suspense APIs. If you're unfamiliar, given the above implementation in mind, this approach could look something like:

// All our imports...
// Here, we use React's `lazy` to defer loading of all this component's code.
const CreateUserModal = lazy(() => import('./CreateUserModal');
// ...

function Dashboard() {
  // State for controlling when our modal should be visible.
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Create user</button>
      {isOpen && (
        // Wrapping our deferred component in this `Suspense`
        // is what allows it to not be rendered until its code
        // has been fetched. The `fallback` prop is what gets
        // rendered in its place in the meantime.
        <Suspense fallback={<p>Loading...</p>}>
          <CreateUserModal />
        </Suspense>
      )}
    </>
  )
}

Enter fullscreen mode Exit fullscreen mode

Not too bad right? And for a lot of cases it suffices. But in this particular example there's now quite a thorny UX issue that's been created: where and how should that fallback be rendered when the button is pressed and we wait for the code to be fetched? In the example above, it's simply going to render <p>Loading...</p> next to the button.

We could pass something like a classic skeleton loader to the fallback, and then wrap the Suspense and modal content with some type of modal frame, so that the frame renders on click but the content contains the skeleton until the code is ready.

This is okay, but then you have the problem of trying to match the skeleton style with the content, including its height. And if the height is not something you control, varying at any time, that's even more complexity.

On top of that, what if you also need to perform some data fetching when the modal mounts (yes, that's not the best pattern, but unfortunately real-world projects don't always present us with the most ideal conditions)? Will you then show a skeleton for the code fetching, and then another skeleton for the data fetching? Well, just like we had callback hell, we now often have skeleton hell and I generally try my best not to contribute πŸ’€πŸ’€πŸ’€

Taking it back to our inline loading state at the beginning, a more subtle approach from the user's perspective would be to stuff that entire Suspense and its fallback within the button itself, rendering something like a spinner when the button is pressed:

<button onClick={() => setIsOpen(true)}>
  {isOpen && (
    <Suspense fallback={<Spinner />}>
      <CreateUserModal close={() => setIsOpen(false)} />
    </Suspense>
  )}{' '}
  Create User
</Button>
Enter fullscreen mode Exit fullscreen mode

Sure, this doesn't seem so bad in this highly contrived and simplified example but it assumes a lot about the structure of our components and code. To take the simplest objection: what if we simply don't want our modal code co-located like that? Too bad! Suspense has to go where you want your loading indicator to go.

You're also a bit limited to how you want your loading state configured. You essentially hand off all your control to Suspense and let it take care of everything. In many cases this may actually be exactly what you want, but sometimes you want more fine-grained control of how exactly the loading is carried out.

So, what is to be done?

Wouldn't it be nice if we could just load our component dynamically and not deal with Suspense at all? While it loads, we could keep track of its loading state and trigger our loading indicator appropriately:

// All our imports...
// Here, set up our module path resolver. It's essentially
// the same thing as before, except without React's `lazy`.
const loadUserModal = () => await('./CreateUserModal');
// Declare the variable we'll eventually load our component into.
let CreateUserModal;

function Dashboard() {
  // State for controlling when our modal should be visible.
  const [isOpen, setIsOpen] = useState(false);
  // State for keeping track of our component loading.
  const [isLoading, setIsLoading] = useState(false);

  async function openUserModal() {
    // If we've already loaded our component, just open & return.
    if (CreateUserModal) {
      setIsOpen(true);
      return;
    }

    // Else, we set our loading state and wait for the module to load.
    setIsLoading(true);
    const module = await loadUserModal();
    CreateUserModal = module.default; // assuming it's a default export.

    // And finally we open the modal and turn our loading off!
    setIsOpen(true);
    setIsLoading(false);
  }

  return (
    <>
      <button onClick={openUserModal}>
        // Here we simply show the spinner when we're 
        // loading. No more `Suspense` boundary!
        {isLoading && <Spinner />} Create User
      </button>
      // Maybe a bunch of other code. We can put it anywhere now!
      {isOpen && <CreateUserModal />}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

And just like that, we have full control of how we dynamically load our components! Though note, for dynamic imports to work you may need this preset if using Babel.

Of course, the way we've done it above is a bit limiting. We're just loading a single, specific component. We're assuming it's a default export. We don't even catch any errors. Also, what if we actually don't want to dynamically import a component but just a normal JS module? You know where I'm going with this...

πŸͺπŸͺπŸͺ

Let's turn this into a reusable hook!

// Our hook takes an array of module path resolvers
function useLazyLoad(resolvers) {
  const [isLoading, setIsLoading] = useState(false);
  const result = useRef();

  // Always return array with same length as the number of components so the
  // hook's consumer can immediately destructure, for example:
  // const [loading, load, [Comp1, Comp2]] = useLazyLoad([lazyComp1, lazyComp2]);
  const placeholderResult = useRef(Array(resolvers.length));

  // This is the function we return for the consumer to
  // call and initiate loading of the component.
  // It's wrapped in a `useCallback` in case they also
  // want to pass it to a memoized component or otherwise
  // include it as a dependency.
  const load = useCallback(async () => {
    // Do nothing if the modules have already been loaded.
    if (result.current) return;

    try {
      setIsLoading(true);

      // Resolve each module.
      const modulePromises = resolvers.map((resolver) => resolver());
      const modules = await Promise.all(modulePromises);

      // If the module has a default export, return it directly,
      // Otherwise, return the entire object and let consumer handle it.
      result.current = modules.map((module) =>
        'default' in module ? module.default : module
      );
    } catch (error) {
      // Do something with the error...
    } finally {
      setIsLoading(false);
    }
  }, []);

  return [isLoading, load, result.current || placeholderResult.current];
}
Enter fullscreen mode Exit fullscreen mode

Then, going back to our previous example, we can now use our hook like so:

// All our imports...
import useLazyLoad from './useLazyLoad';
const lazyUserModal = () => await('./CreateUserModal');

function Dashboard() {
  const [isOpen, setIsOpen] = useState(false);
  // We call our hook here, passing it the resolver we defined
  // above wrapped in an array. Notice we immediately
  // destructure the result but that's completely optional!
  const [isLoading, load, [CreateUserModal]] = useLazyLoad([lazyUserModal]);

  async function openUserModal() {
    // Here we call the `load` function returned from our hook.
    await load();
    // And open the modal!
    setIsOpen(true);
  }

  // Nothing else changes!
  return (
    <>
      <button onClick={openUserModal}>
        {isLoading && <Spinner />} Create User
      </button>
      // ...
      {isOpen && <CreateUserModal />}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now all the logic is tucked away neatly in our little lazy hook and we just call it whenever we need to! Much better πŸ€™

Conclusion

While React's lazy and Suspense APIs definitely have their place and generally make async loading of components (and now, with a compatible library, data!) a piece of cake, sometimes you want handle things on your own.

Suspense boundaries can get messy and difficult to manage. Your code may be structured in a way that doesn't allow for just sticking Suspense wherever you want. Maybe you want more fine-grained control of how the loading itself is carried out. In general, things tend to not play out as cleanly as they do in tutorials (actually, they pretty much never do!). So in those cases, you can try out this approach and let me know how it works for you!

Note on React 18 and useTransition

If you're on React 18, you can achieve something quite similar to this by using React's lazy and new hook useTransition:

// All our other imports...
const CreateUserModal = lazy(() => import('../components/CreateUserModal'));

// We'll flip this after our component loads to skip
// subsequent, unnecessary calls to `startTranstion`.
let isLoaded = false;

function Dashboard() {
  const [isOpen, setIsOpen] = useState(false);
  // Call the transition hook. `isPending` tells us the
  // state the transition is in (just like our `isLoading`),
  // and `startTransition` initiates the transition (like our `load`).
  const [isPending, startTransition] = useTransition();

  function openCreateUserModal() {
    // If we've already loaded the code, simply open the modal.
    if (isLoaded) {
      setIsOpen(true);
      return;
    }
    // Else, tell React that we consider setting opening the modal
    // a transition, which you can think of as deprioritizing
    // and as such won't occur until after the component has loaded.
    startTransition(() => setIsOpen(true));
    isLoaded = true;
  }

  // This time, just a change in variable name!
  return (
    <>
      <button onClick={openUserModal}>
        {isPending && <Spinner />} Create User
      </button>
      // ...
      {isOpen && <CreateUserModal />}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

You can play around with a demo of that as well at the embed below. Though note that I'm unaware of a way to add a delay to startTransition, so the loading state is near-instantaneous.

And that's all she wrote!

Oldest comments (0)