Alex Fedoseev
2020, 31 Oct

Cool Things You Can Do with First-Class Modules in ReScriptReact

After years of working with ReScript (formerly known as BuckleScript), I find its module system to be one of the best. The more I used the language, the more extensively I used its modules. One of the critical parts of the ecosystem that leverages it heavily is ReScriptReact. This post is about the not-so-widely-known advanced feature of the language that helps to build handy abstractions over ReScriptReact components.

Components as props

It is common in React.js apps to pass around components via props. Since in JS, a React component is a function (or class, which is just a special kind of function), it's trivial to pass it around. In ReScriptReact, components are modules, which makes the component-via-props pattern hardly usable.

One of the possible solutions is to replace a component with a function that produces React.element.

Consider a Button component that renders an abstract icon. Instead of expecting an icon component, it can expect a function that takes some input (like size, color, etc.) and returns a React.element.

rescript
@react.component
let make = (
  ~size: Size.t,
  ~renderIcon: (~size: Size.t) => React.element,
  ~onClick,
  ~children,
) => {
  <button
    className={
      switch size {
      | SM => "sm"
      | MD => "md"
      }
    }
    onClick
  >
    {renderIcon(~size)}
    {children}
  </button>
}

And then it can be used like this:

rescript
<Button icon={(~size) => <BinIcon size />}>
  {"Delete"->React.string}
</Button>

It's kind of okay. But when such a prop is used often across the app, proxying props gets increasingly annoying. It gets even more annoying when there are multiple arguments to pass: size, color, className, etc.

Ideally, I want to provide an icon component, and the Button could figure it all out internally.

rescript
<Button icon={BinIcon}>
  {"Delete"->React.string}
</Button>

It turned out that it's possible with one constraint and a little bit of additional work.

First-class modules

As mentioned, a ReScriptReact component is a module. In ReScript, modules exist in a separate language space from common types and functions. It's impossible to take a module as-is and pass it as an argument to a function. So <Button icon={BinIcon}> wouldn't work.

Luckily, OCaml has an advanced feature called first-class modules. It allows a module to be brought into a function space by packing it into a special container. I.e., it can be passed to or returned from a general function.

But there is one constraint exposed. Whenever a first-class module pops up, you need to have its type at hand. Let's look at the example.

In an app, there might be different Human modules, each containing a human's age. For example:

rescript
module Teenager = {
  let age = 17
}

The task is to implement an age function, which takes such a module and returns containing age.

To pass a module to a function, it needs to be turned into a first-class module. Let's see how modules can be packed into and unpacked from a first-class module container:

rescript
// Packing
let human = module(Teenager)

// Unpacking
let module(Teenager) = human

A naive implementation of the age function would be:

rescript
let age = human => {
  // Unpacking a module
  let module(Human) = human
  // Now it's possible to access the internals of the module
  Human.age
}

But it wouldn't work because type inference won't kick in when it comes to first-class modules. Such an argument must be explicitly annotated to let the compiler know what this thing is. Hence, we need to define a type for a Human module.

If you ever did interface files (.mli/.rei/.resi), it will be familiar to you.

rescript
module type Human = {
  let age: int
}

The final implementation of the age function is:

rescript
let age = (human: module(Human)) => {
  let module(Human) = human
  Human.age
}

module(Teenager)->age // returns 17

Now, let's apply this to the button with icon case.

Back to the Button

As you might have already figured, in ReScriptReact, it wouldn't be possible to pack any icon component with any set of props into a first-class module. To be able to pass an icon to the Button, the former must conform to a strict interface. For example, an icon component must accept a size prop and return a React.element.

rescript
module type Icon = {
  @react.component
  let make: (~size: Size.t) => React.element
}

With this constraint, it's possible to implement the Button the way we want it.

rescript
module Button = {
  @react.component
  let make = (~size: Size.t, ~icon: module(Icon), ~onClick, ~children) => {
    let module(Icon) = icon

    <button
      className={
        switch size {
        | SM => "sm"
        | MD => "md"
        }
      }
      onClick
    >
      <Icon size />
      children
    </button>
  }
}

And here we have it on the application side of things:

rescript
module BinIcon = {
  @react.component
  let make = (~size) => {
    <Svg size> <path /> </Svg>
  }
}

<Button size=MD icon=module(BinIcon)>
  {"Delete"->React.string}
</Button>

Loading assets using dynamic imports

Another use case where first-class modules give a huge helping hand is dynamic asset loading. To optimize the size of the downloaded code, JS apps use dynamic import() to load JS chunks on demand. While it's trivial to bind to the import() function itself, using the result it returns is far less so.

rescript
module Module = {
  @val external load: string => Promise.t<'a> = "import"
}

Module.load("./path/to/Component.bs.js") // compiles to `import("./path/to/Component.bs.js")`

Regarding UI, a pattern is similar to data fetching: a user requests a specific UI, an app starts fetching a JS chunk with feedback in UI, e.g., in the form of a Spinner. And when it's loaded, it renders a loaded UI on the screen.

Under the hood, when a promise with a loaded asset gets resolved, the latter will be classified as a first-class module that contains a React component.

So the API of the loader would be:

rescript
<MyChunkLoader>
  {(module(MyChunk)) => <MyChunk />}
</MyChunkLoader>

Where MyChunk is a React component:

MyChunk.resrescript
@react.component
let make = () => {
  <div> {"Hi!"->React.string} </div>
}

Before making an abstraction for loading any type of module, let's first implement MyChunkLoader for this specific use case.

To load and render a module, a defined type is needed since we're dealing with first-class modules.

rescript
module type MyChunk = {
  @react.component
  let make: unit => React.element // component without props
}

Then we can implement MyChunkLoader for this component:

rescript
type state =
  | Loading
  | Ok(module(MyChunk))
  | Error

type action =
  | Render(module(MyChunk))
  | Fail

let reducer = (_state, action) =>
  switch action {
  | Render(component) => Ok(component)
  | Fail => Error
  }

@react.component
let make = (~children: module(MyChunk) => React.element) => {
  let (state, dispatch) = reducer->React.useReducer(Loading)

  React.useEffect0(() => {
    Module.load("./MyChunk.bs.js")
      ->Promise.result
      ->Promise.wait(x =>
        switch x {
        | Ok(component) => Render(component)->dispatch
        | Error(_) => Fail->dispatch
        }
      )
    None
  })

  switch state {
  | Loading => "Loading..."->React.string
  | Ok(component) => component->children
  | Error => "Oh no"->React.string
  }
}

A note on Promises

I use a slightly modified version of standard ReScript's Js.Promise module in apps and articles, because the standard one is a bit awkward to use.

Even though this implementation works for this specific use case, it wouldn't compile for another component that expects props. To make it work with an abstract component, let's start by extracting parts specific to MyChunk into a separate module: MyChunk module type and loader function.

rescript
module MyLoadableChunk = {
  module type t = {
    @react.component
    let make: unit => React.element
  }

  // Pay attention that we load the generated `.bs.js` asset, not the original `.res` source
  let loader = () => Module.load("./MyChunk.bs.js")
}

The idea is to create an abstraction (call it Loadable) which accepts such a spec module and returns an implementation similar to MyChunkLoader but for the provided spec:

rescript
module MyChunkLoader = Loadable(MyLoadableChunk)

In ReScript, the thing that takes module(s) and returns a new module constructed from the input is called a functor. It is a function of a module’s space. So the Loadable bit in the snippet above should be a functor.

The first step is to describe a type of input module:

rescript
module type Component = {
  // Type of the module the abstraction will be loading
  module type t
  // Function that invokes loading and returns a Promise with a first-class module
  let loader: unit => Promise.t<module(t)>
}

And the second step is to wrap the initial implementation of MyChunkLoader into a functor:

Loadable.resrescript
module Make = (Component: Component) => {
  type state =
    | Loading
    | Ok(module(Component.t))
    | Error

  type action =
    | Render(module(Component.t))
    | Fail

  let reducer = (_state, action) =>
    switch action {
    | Render(component) => Ok(component)
    | Fail => Error
    }

  @react.component
  let make = (~children: module(Component.t) => React.element) => {
    let (state, dispatch) = reducer->React.useReducer(Loading)

    React.useEffect0(() => {
      Component.loader()
        ->Promise.result
        ->Promise.wait(x =>
          switch x {
          | Ok(component) => Render(component)->dispatch
          | Error(_) => Fail->dispatch
          }
        )
      None
    })

    switch state {
    | Loading => "Loading..."->React.string
    | Ok(component) => component->children
    | Error => "Oh no"->React.string
    }
  }
}

Now we have everything in place to load MyChunk.res dynamically using Loadable functor. Create MyChunkLoader.res next to the MyChunk.res:

MyChunkLoader.resrescript
module Component = {
  module type t = {
    @react.component
    let make: unit => React.element
  }
  let loader = () => Module.load("./MyChunk.bs.js")
}

include Loadable.Make(Component)

One more important improvement that should be made is to avoid manual typing of the loadable module since it's error-prone. God bless OCaml, we can infer a module type from the implementation using the module type of construction:

MyChunkLoader.resrescript
module Component = {
  module type t = module type of MyChunk
  let loader = () => Module.load("./MyChunk.bs.js")
}

include Loadable.Make(Component)

It should be working now.

rescript
<MyChunkLoader>
  {(module(MyChunk)) => <MyChunk />}
</MyChunkLoader>

Loading non-ReScript assets

If you want to load a non-ReScript asset, such as a React component written in JS, the steps would be the same, except instead of having MyChunk.res with a ReScript implementation, there will be JsChunk.res with a binding to JS implementation:

JsChunk.resrescript
@module("./JsChunk.jsx") @react.component
external make: unit => React.element = "default"

Bonus

Using this technique, it's possible to load not only ReScript or JS assets, but anything that you can render in your environment. E.g., if you have Markdown files, you can load them dynamically and render right in a ReScript app using MDX:

MdxChunk.resrescript
@module("./MdxChunk.mdx") @react.component
external make: unit => React.element = "default"

You can find code examples in this repository.