May 25, 2023

First-class Dynamic Import Support

A tour of new capabilities coming in 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.

When developing apps in JavaScript, every relevant source file needs to be bundled up and shipped to the browser. As the app grows, it's usually recommended to dynamically load parts of the app code on demand as separate JS modules.

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

Even though ReScript has been able to bind to import calls via externals, doing so was quite hard to maintain due 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 JS file locations.

To fix this, we're excited to announce that ReScript v11 will ship with first-class support for dynamic imports as part of the language!

Let's dive in.

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

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 this component using the React.lazy_ binding.

React.lazy_ receives a callback function that returns a promise that resolves to a React component and then returns a lazy loaded version of the same React component:

RESCRIPT
// React.resi let lazy_: (unit => promise<React.component<'props>>) => React.component<'props>

In order to dynamically import our <Title /> component, just pass the result of a dynamic import of Title.make to React.lazy_:

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

<LazyTitle /> behaves exactly the same as the wrapped <Title /> component, but will be lazy loaded via React's built in lazy mechanism.

Note that APIs for React.lazy ship with the latest official @rescript/react bindings.

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.

Conclusions

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.

More importantly, dynamic imports will help us providing end-users with faster load times and quicker app interaction, even on slow network connections.

As always, we're eager to hear about your experiences with these new features. Don't hesitate 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