React.lazy - supported or workaround?

I’m trying to get code splitting to work but don’t see a React.lazy function. When I searched for “lazy” I also couldn’t find anything relevant in this forum or on the RescriptReact github issues about it. Is this something I need to roll-my-own for, maybe with external?

While working on the bindings for solidJs, I also had to find a way to bind the lazy function provided there.

Afterwards I made a short test and the bindings also work for react. It turned out the function signatures are basically identical. These are the bindings I came up with (pretty sure there is some improvement potential there, but so far they work well):

module Suspense = {
  @module("react") @react.component
  external make: (~fallback: React.element, ~children: React.element, unit) => React.element =
    "Suspense"
}

module Lazy = {
  module type T = {
    let make: React.component<unit>
    let makeProps: unit => unit
  }
  type dynamicImport = Js.Promise.t<{"make": React.component<unit>}>

  @module("react")
  external lazy_: (unit => Js.Promise.t<{"default": React.component<unit>}>) => React.component<unit> =
    "lazy"

  let make: (unit => dynamicImport) => module(T) = func => {
    let lazyPromise = lazy_(() => func()->Js.Promise.then_(comp => {
        Js.Promise.resolve({"default": comp["make"]})
      }, _))

    module Return = {
      let make = lazyPromise
      let makeProps = () => ()
    }

    module(Return)
  }
}

@val
external import_: string => Lazy.dynamicImport = "import"

In general, handling dynamic imports with ReScript is problematic, since you cannot reference the .res files directly. You have to load the generated .bs.js files instead. But this works okay as long as you use the in-source: true option in bsconfig.json. (At least for me using vite)

This is how you could use it in a component:

@react.component
let make = () => {
  let module(Comp) = Bindings.Lazy.make(() => Bindings.import_("./Component.bs"))

  <Comp />
}

I also created a tiny example repo to try it out: rescript-react-lazy-example

The bindings only support components without props but in general it would be possible to change the Lazy module into a Functor that would allow the passing of props to components.

2 Likes

@Fattafatta Nice! I think it is very valuable for you to create a single library for this bindings.

@Fattafatta Can you help me with an example for multiple props case?

Building the bindings for components with props is a little more complicated than the simple example above. The main challenge is to make the component have the right type without having to define all types by hand (even when using a Functor).

Luckily ReScript supports the type of operator for modules which makes it a lot more usable. But still there is a lot of “magic” necessary to make dynamic imports work with components.

I created a new repository that contains minimal bindings for React.lazy. It also supports component with props. You can check it out here:

rescript-react-lazy

I didn’t have much time to test it in depth. If you find any problems with it, just let me know.

1 Like

Thank you for this @Fattafatta. I’ve been playing around with your code for a week and this is what I came up with that would both compile and work in the browser, so I thought I would share with everyone:

To surmount the type issue we can add component-specific bindings to import and React.lazy, along with a type for the shape of the props, or unit if there are no props. As an example I’ll use a Loading component that shows a spinner and takes optional props for a color and a label:

//child to lazy load
type propShape = {"fillColor": option<string>, "label": option<string>}//or unit if no props

@val
external import_: string => Promise.t<{"make": React.component<propShape>}> = "import"

@module("react")
external lazy_: (unit => Promise.t<{"default": React.component<propShape>}>) => React.component<propShape> = "lazy"

@react.component
let make = (~fillColor="fill-stone-100", ~label="") => {
  <>...</>
}

Then in a parent component where you want to use the lazy-loaded component:

//parent lazy-loading the child
@react.component
let make = (props) => {
  let loading = React.createElement(
    Loading.lazy_(() =>
      Loading.import_("./Loading.bs")->Promise.then(comp => {
        Promise.resolve({"default": comp["make"]})
      })
    ),
    Loading.makeProps(~label="lobby...", ()),//or just () if no props
  )
  ...
  <div>
    <React.Suspense fallback=React.null> loading </React.Suspense>
  </div>
}

Since I didn’t output a new module we can’t use JSX (at least, I couldn’t find a way), so we store a call to React.createElement in a variable. The signature of createElement is (component<'props>, 'props) => element. We are lazy-loading the component and then to get the props we call the component’s makeProps function, which is available to other components along with make. The return value of makeProps must match the propShape defined in the component. Don’t forget to end the call to makeProps with a unit (). If the child has no props, just use a unit () instead of calling its makeProps function. Then, as required by React, we place the variable holding the component in a React.Suspense (binding from rescript-react).

I tried to make a functor out of this but since it had to be called with the module you want to lazy-load, the component would be imported in the JS, negating the lazy-load and defeating the purpose. Note that the descendants of a lazy-loaded component do not have to be lazy-loaded themselves to avoid being requested on initial page load.

//compiled JS

The compiled lazy-loaded child component does not change since we just add a type and two externals. If we try to parameterize the file name for the call to import by exporting it from the child component, this works but also kills the lazy-load.

The compiled parent component does not change much. It has one fewer import for the lazy-loaded child and no additional currying. The child’s instantiation and use look like this:

  var loading = React.createElement(React.lazy(function (param) {
    return import("./Loading.bs").then(function (comp) {
      return Promise.resolve({
        default: comp.make
      });
    });
  }), {
    label: "games..."
  });
  ...
  React.createElement(
    React.Suspense, {
      children: loading,
      fallback: null
    }
  )

The child component is not requested by the browser until it is needed by the parent and there are no console errors, so it seems to work! Lemme know what you think!