How to organise the universal code

While using server-reason-react it's important to know how to organise the code. Sometimes you may want to have components that are shared between the client and the server, and sometimes you want to have components that are only used by the client or the server.

In this guide, we will asume you are using Melange and dune, and will show a few examples of universal code and how to setup the dune files accordingly.

Pure universal library

It's a library without any client or server dependency, you can just have a library with all modes: (modes native byte melange). This is common for type-only libraries or libraries that only rely on the standard library. I often refer to this as "pure universal" library.

A tiny library to handle remote data named Remote_data:

(library
 (name remote_data)
 (modes native melange)) ; Contains both modes for melange and native
type t('data, 'error) =
  | NotAsked
  | InitialLoading
  | Loading('data)
  | Failure('error)
  | Success('data);

let map = (remoteData, fn) =>
  switch (remoteData) {
  | NotAsked => NotAsked
  | InitialLoading
  | Loading(_) => InitialLoading
  | Failure(error) => Failure(error)
  | Success(data) => Success(fn(data))
  };

let getWithDefault = (remoteData, defaultValue) =>
  switch (remoteData) {
  | NotAsked
  | InitialLoading
  | Loading(_)
  | Failure(_) => defaultValue
  | Success(data) => data
  };

let isLoading =
  fun
  | InitialLoading
  | Loading(_) => true
  | _ => false;

This is a cut down version of the library for demonstration purposes, you can imagine to have all necessary functions to operate on this type.

This library can be used in both "native" and "melagne" stanzas interchangeably.

Same API, different implementations

There are some other cases where you want to expose the same API, but the implementation is different.

For example, another tiny example: you may want to have a library that exposes a function to get the current time. On the client, you may want to use the browser API, while on the server you may want to use the system time.

dune allows to have 2 libraries with the same name, but available in different modes. For example:

(library
  (name url_js)
  (modes melange)
  (libraries melange.js)
  (modules Url)
  (wrapped false))

(library
  (name url_native)
  (modes native)
  (modules Url)
  (wrapped false))

url_js and url_native are two different libraries, but they expose the same module called Url with the same API.

Both libraries need to be (wrapped false) so they expose all the modules (which in this case is only Url) directly.

wrapped true means that the library is wrapped in a entry module, so the modules are exposed under the library name. In this case, wrapped false expose the modules directly.

copy_files

In order to reuse the same code, you can use (copy_files ...). It seems hacky, and eventually we will have better ways of doing so, but is the method I found to be more reliable in terms of developer experience, mostly editor support and error messages.

- src
  - client/
    - dune
  - server/
    - shared/
        <library-code-here>
    - dune
(* src/client/dune *)

(library
  (name url_js)
  (modes melange)
  (libraries melange.js)
  (wrapped false)
  (modules Url)
  (preprocess (pps melange.ppx))

(copy_files#
 (mode fallback) ; `mode fallback` means you can override files in the client folder
 (files "../native/shared/**.{re,rei}"))
(* src/server/dune *)

(library
  (name url_native)
  (modes native)
  (modules Url)
  (wrapped false))

Here's an example https://github.com/ml-in-barcelona/server-reason-react/tree/main/demo/universal

reason-react and server-reason-react

Asuming you want to share react.components between the client and the server, you can use the same technique as above.

(library
 (name shared_js)
 (modes melange)
 (libraries reason_react melange.belt bs_webapi)
 (wrapped false)
 (preprocess
  (pps melange.ppx reason-react-ppx)))

(copy_files# "../native/lib/*.re")

(library
 (name shared_native)
 (modes native)
 (libraries
  server-reason-react.react
  server-reason-react.reactDom
  server-reason-react.belt
  server-reason-react.webapi)
 (wrapped false)
 (preprocess
  (pps
    server-reason-react.ppx
    server-reason-react.browser_ppx
    server-reason-react.melange_ppx)))

(copy_files# "../*.re")

This will expose all modules under a `Shared` module. You can then use those modules in both the client and the server.

// client.re

switch (ReactDOM.querySelector("#root")) {
| Some(el) =>
  let root = ReactDOM.Client.hydrateRoot(el);
  ReactDOM.Client.hydrate(<Shared.App />, root);
| None => Js.log("Can't find a 'root' element")
};
// server.re
// Given a random server library, and a random Page component

module Page = {
  [@react.component]
  let make = (~children, ~scripts) => {
    <html>
      <head>
        <meta charSet="UTF-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1.0"
        />
        <title> {React.string("Server Reason React demo")} </title>
        <link
          rel="shortcut icon"
          href="https://reasonml.github.io/img/icon_50.png"
        />
        <script src="https://cdn.tailwindcss.com" />
      </head>
      <body> <div id="root"> children </div> </body>
    </html>;
  };
};

// ...
req => {
  let html = ReactDOM.renderToString(<Page> <Shared.App /> </Page>);
  Httpd.Response.make_string(Ok(html));
}

Note on virtual_libraries

There's a better mechanismo of doing the same thing by dune, which is Virtual libraries.

However, there are a few limitations on virtual libraries:

I found that this mechanism is not as reliable as copy_files, and it's not well supported by editors. I would recommend to use copy_files instead, while we explore better ways of doing so with the dune team.

Next

  1. Exclude client code from the native build
  2. Externals and melange attributes