Sven Kadak

Code-Splitting a React Application in ReasonML

As your React application grows bigger, it's time to think about cutting the bundle into smaller pieces, so that the users of your website could enjoy a better experience.

In this article, we will take a look at how to split a React application by pages in ReasonML.

Interactive demo: reason-react-code-splitting.surge.sh

For more details, see the demo project on GitHub.

1. Configuration

Only the bits that interest us are included.

package.json

{
  "dependencies": {
    "react": "^16.8.1",
    "react-dom": "^16.8.1",
    "reason-react": ">=0.7.0"
  },
  "devDependencies": {
    "bs-platform": "^5.0.6",
    "html-webpack-plugin": "^3.2.0",
    "webpack": "^4.0.1",
    "webpack-cli": "^3.1.1"
  }
}

bsconfig.json

{
  "bs-dependencies": ["reason-react"],
  "reason": {
    "react-jsx": 3
  },
  "suffix": ".bs.js"
}

webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: {
    app: "./src/Index.bs.js"
  },
  output: {
    path: path.join(__dirname, "build"),
    filename: "[name]-[chunkhash].bundle.js",
    chunkFilename: "[name]-[chunkhash].bundle.js"
  },
  optimization: {
    runtimeChunk: "single",
    splitChunks: {
      chunks: "all"
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: "src/index.html"
    })
  ]
};

2. import binding

In order to dynamically load our components, let's first create a binding for the import function.

/* External.re */
[@bs.val] external import: string => Js.Promise.t('a) = "import";

3. Custom React.createElement binding

In order to have fully checked code, we must declare our own createElement function. ReasonReact's createElement accepts any value as props which can lead to a broken application without you knowing because the compiler says everything is correct. One of the reasons we use ReasonML is its powerful type system and we should take advantage of it whenever we can.

With the official createElement function, whenever you forget to add a prop, the makeProps function generated by ReasonReact's PPX is partially applied. The value, in this case, is a function not a JS object that the component expects.

Our createElement function will accept Js.t('props) instead of just 'props:

/* External.re */
module React = {
  [@bs.module "react"]
  external createElement:
    (React.component(Js.t('props)), Js.t('props)) => React.element =
    "createElement";
};

4. Route module

The Route module is used to change routes. That in turn triggers page changes.

/* Route.re */

type t =
  | Home
  | Services
  | Contact;

let go = route =>
  ReasonReactRouter.push(
    switch (route) {
    | Home => "/"
    | Services => "/services"
    | Contact => "/contact"
    },
  );

5. Page components

The application has 4 pages: HomePage, ServicesPage, ContactPage and NotFoundPage. They are just simple components as usual, no magic involved.

/* HomePage.re */
[@react.component]
let make = (~text) => <div> text->React.string ;

/* ServicesPage.re */
[@react.component]
let make = () => <div> "Services"->React.string ;

/* ContactPage.re */
[@react.component]
let make = () => <div> "Contact"->React.string ;

/* NotFoundPage.re */
[@react.component]
let make = () => <div> "Not Found"->React.string ;

6. Page module

As we don't want to cram all of our code into a single bundle, we will use the import function defined earlier to notify Webpack that we would like to load our page components lazily. When the user initiates a page change, Webpack will only download the code needed by that page.

To lazily load a component, we use

External.import("./HomePage.bs.js")
|> then_(component =>
     resolve(
       External.React.createElement(
         component##make,
         HomePage.makeProps(~text="Home", ()),
       ),
     )
   )

When the import promise resolves, we have successfully loaded the page module and can create the React element. The element is sent to our App component where it will replace the active page element.

/* Page.re */

type t =
  | Home
  | Services
  | Contact
  | NotFound;

let fromPath = path =>
  switch (path) {
  | [] => Home
  | ["services"] => Services
  | ["contact"] => Contact
  | _ => NotFound
  };

let load = page =>
  Js.Promise.(
    switch (page) {
    | Home =>
      External.import("./HomePage.bs.js")
      |> then_(component =>
           resolve(
             External.React.createElement(
               component##make,
               HomePage.makeProps(~text="Home", ()),
             ),
           )
         )
    | Services =>
      External.import("./ServicesPage.bs.js")
      |> then_(component =>
           resolve(
             External.React.createElement(
               component##make,
               ServicesPage.makeProps(),
             ),
           )
         )
    | Contact =>
      External.import("./ContactPage.bs.js")
      |> then_(component =>
           resolve(
             External.React.createElement(
               component##make,
               ContactPage.makeProps(),
             ),
           )
         )
    | NotFound =>
      External.import("./NotFoundPage.bs.js")
      |> then_(component =>
           resolve(
             External.React.createElement(
               component##make,
               NotFoundPage.makeProps(),
             ),
           )
         )
    }
  );

7. App component

Whenever a page changes, the App component will replace the active page element with the new one.

/* App.re */

type action =
  | SetPageElement(React.element);

type state = {pageElement: React.element};

let loadPage = (dispatch, url: ReasonReactRouter.url) =>
  Js.Promise.(
    url.path
    |> Page.fromPath
    |> Page.load
    |> then_(element => {
         dispatch(SetPageElement(element));
         resolve();
       })
    |> ignore
  );

[@react.component]
let make = () => {
  let (state, dispatch) =
    React.useReducer(
      (state, action) =>
        switch (action) {
        | SetPageElement(pageElement) => {pageElement: pageElement}
        },
      {pageElement: React.null},
    );

  React.useEffect0(() => {
    open ReasonReactRouter;

    dangerouslyGetInitialUrl() |> loadPage(dispatch);
    let watcherId = watchUrl(url => loadPage(dispatch, url));

    Some(() => unwatchUrl(watcherId));
  });

  <div className="container">
    <div className="menu">
      <div className="menuItem" onClick={_ => Route.go(Home)}>
        "Home"->React.string
      </div>
      <div className="menuItem" onClick={_ => Route.go(Services)}>
        "Services"->React.string
      </div>
      <div className="menuItem" onClick={_ => Route.go(Contact)}>
        "Contact"->React.string
      </div>
    </div>
    <div className="page"> {state.pageElement} </div>
  </div>;
};

8. Why not use React.lazy?

The only reason why this solution does not use React.lazy is that it destroys the active page component before downloading the loaded component. This causes a slight flicker, which IMO is not desirable. To prevent this anomaly, I've decided that the introduced solution is better for lazy loading page components. For other use cases it's still a viable option.

In fact, the first version of the demo project was implemented using React.lazy. If you're interested, check out the react-lazy branch of the demo project.

9. Existing solutions

  • reason-loadable: I previously used a solution similar to reason-loadable, but in my opinion, it's too complex. I decided to dig deeper to make the implementation as simple as possible and this is the solution I came up with.

That's about it. Have fun splitting modules!