Jun 5, 2023

First-class Dynamic Import Support

A tour of new capabilities coming to ReScript v11

ReScript Team
Core Development

This is the third post covering new capabilities that'll ship in ReScript v11. You can check out the first post on Better Interop with Customizable Variants and the second post on Enhanced Ergonomics for Record Types.

Introduction

When developing apps in JavaScript, every line of code eventually needs to be bundled up and shipped to the browser. As the app grows, it's usually a good idea to split up and load parts of the app code on demand as separate JS modules to prevent bundle bloat.

To accomplish this, browsers provide support for dynamic loading via the globally available import() function to allow code splitting and lazy loading and ultimately reducing initial load times for our applications.

Even though ReScript has been able to bind to import calls via external bindings, doing so was quite hard to maintain for to the following reasons:

  1. An import call requires a path to a JS file. The ReScript compiler doesn't directly expose file paths for compiled modules, so the user has to manually find and rely on compiled file paths.

  2. The return type of an import call needs to be defined manually; a quite repetitive task with lots of potential bugs when the imported module has changed.

Arguably, these kind of problems should ideally be tackled on the compiler level, since the ReScript compiler knows best about module structures and compiled JS file locations — so we finally decided to fix this.

Today we're happy to announce that ReScript v11 will ship with first-class support for dynamic imports as part of the language.

Let's have a look!

Import Parts of a Module

We can now use the Js.import function to dynamically import a value or function from a ReScript module. The import call will return a promise, resolving to the dynamically loaded value.

For example, imagine the following file MathUtils.res:

RESCRIPT
// MathUtils.res let add = (a, b) => a + b let sub = (a, b) => a - b

Now let's dynamically import the add function in another module, e.g. App.res:

RESCRIPT
// App.res let main = async () => { let add = await Js.import(MathUtils.add) let onePlusOne = add(1, 1) RescriptCore.Console.log(onePlusOne) }

This compiles to:

JAVASCRIPT
async function main() { var add = await import("./MathUtils.mjs").then(function(m) { return m.add; }); var onePlusOne = add(1, 1); console.log(onePlusOne); }

Notice how the compiler keeps track of the relative path to the module you're importing, as well as plucking out the value you want to use from the imported module.

Quite a difference compared to doing both of those things manually, right? Now let's have a look at a more concrete use-case with React components.

Use-case: Importing a React component

Note: This section requires the latest @rescript/react bindings to be installed (0.12.0-alpha.2 and above).

Our dynamic import makes tasks like lazy loading React components a simple one-liner. First let's define a simple component as an example:

RESCRIPT
// Title.res @react.component let make = (~text) => { <div className="title">{text->React.string}</div> }

Now let's dynamically import the <Title/> component by passing the result of our dynamic import to React.lazy_:

RESCRIPT
module LazyTitle = { let make = React.lazy_(() => Js.import(Title.make)) } let titleJsx = <LazyTitle text="Hello!" />

That's all the code we need! The new <LazyTitle /> component behaves exactly the same as the wrapped <Title /> component, but will be lazy loaded via React's built-in lazy mechanism.

Needless to say, all the code examples you've seen so far are fully type-safe.

Import a Whole Module

Sometimes it is useful to dynamically import the whole module instead. For example, you might have a collection of utility functions in a dedicated module that tend to be used together.

The syntax for importing a whole module looks a little different, since we are operating on the module syntax level; instead of using Js.import, you may simply await the module itself:

RESCRIPT
// App.res let main = async () => { module Utils = await MathUtils let twoPlusTwo = Utils.add(2, 2) RescriptCore.Console.log(twoPlusTwo) }

And, the generated JavaScript will look like this:

JS
async function main() { var Utils = await import("./MathUtils.mjs"); var twoPlusTwo = Utils.add(2, 2); console.log(twoPlusTwo); }

The compiler correctly inserts the module's import path and stores the result in a Utils variable.

Try it out!

Feel free to try out our new dynamic import feature with the latest beta release:

npm install rescript@11.0.0-beta.1

Please note that this release is only intended for experiments and feedback purposes.

Conclusion

The most important take away of the new dynamic imports functionality in ReScript is that you'll never need to care about where what you're importing is located on the file system - the compiler already does it for you.

We hope that it will help shipping software with better end-user experience with faster load times and quicker app interaction, especially on slower network connections.

As always, we're eager to hear about your experiences with our new features. Feel free to share your thoughts and feedback with us on our issue tracker or on the forum.

Happy hacking!

Want to read more?
Back to Overview