DEV Community

Cover image for Reason and React Meta-Frameworks
Paul Bacchus
Paul Bacchus

Posted on

Reason and React Meta-Frameworks

In my previous post on trying to use the NextJS App Router and Reason I described some of the problems and limitations of their compatibility with one another. With the release of Melange 2 I decided to see if the new features of Melange 2 could help to increase the compatibility of Reason and the NextJS App Router. I have also documented some of the things learnt after trying Melange (v1) with Astro and Remix.

Melange 2

Melange 2 introduces a couple of new features that help to make Reason and ReasonReact more compatible with the NextJS App Router.

@mel.as

The @mel.as attribute allows us to redefine a name in the output JavaScript. Using @mel.as we can solve one of the previous problems with NextJS API route functions and Reason. NextJS expects API route functions to export a function with the name of a HTTP method. With @mel.as we can rename our Reason API function to the HTTP method we want in the output JS and get no export related errors from NextJS:

[@mel.as "POST"]
let handler = () => {
    ...
};
Enter fullscreen mode Exit fullscreen mode

Will be compiled to:

function POST(param) {
    ...
}

export {
  POST ,
}
Enter fullscreen mode Exit fullscreen mode

Astro API endpoints also require the route function to be named after a HTTP method, so this will be useful for Astro as well.

$$default

Another previous compatibility problem was the export of $$default whenever default was used as the function name in ReasonReact instead of make. The export of $$default would cause NextJS to complain and not compile. In Melange 2 $$default is still present but it is exported properly in the output JS:

[@react.component]
let default = () => {
    ...
};
Enter fullscreen mode Exit fullscreen mode

Compiles to:

function Page$default(Props) {
    ...
}

var $$default = Page$default;

export {
  $$default as default,
}
Enter fullscreen mode Exit fullscreen mode

@mel.as and the changes to $$default make Reason and ReasonReact much more compatible with the NextJS App Router.

Structuring a Reason and React meta-framework app

After experimenting with Reason plus Astro, NextJS, and Remix, I think there are two main ways to structure a React meta-framework app for use with Reason, and it depends upon how much Reason you want to use and how you are deploying your app. If you want to write a few components in ReasonReact then it is best to promote (i.e., copy) the output JS back into the src folder of your app. If you want to write most of your app in Reason then it is best to add the meta-framework files as Melange dependencies and then promote all the output JS out of the _build folder and back into the root.

Why promote

Developing in the OCaml way can be done by copying all of the meta-framework files to the _build folder as Melange dependencies and then serving the app from the JavaScript output folder in _build. However, I have found that this doesn't always go smoothly and can cause the meta-framework development tooling to behave in unexpected ways. You could try and debug these unexpected behaviours, but ...

no time for that

I have found that the meta-framework dev tooling works much better if you promote the output JS into a folder that the meta-framework dev tooling expects to be used.

Promoting the output code also makes it much easier to deploy an application via linking a Git repository to a platform like Vercel or Netlify and deploying on push.

Promoting components

If your are only using ReasonReact to create a few components then it is best to promote the output JS of those components back into the src folder. For example, if you have an Astro app like:

/
├── _build/
├── public/
├── src/
│   ├── dune
│   ├── layouts/
│   │   └── Layout.astro
│   ├── pages/
│   │   ├── dogs.astro
│   │   ├── jokes.astro
│   │   └── index.astro
│   └── reason_components/
│       ├── Dog.re
│       ├── Dog.rei
│       ├── Joke.re
│       ├── Joke.rei
│       └── dune
├── package.json
├── astro-reason.cfg.mjs
├── <project_name>.opam
├── dune
├── dune-project
└── Makefile
Enter fullscreen mode Exit fullscreen mode

After promoting the output of the reason_components folder you will end up with:


/
├── _build/
├── public/
├── src/
│   ├── dune
│   ├── layouts/
│   │   └── Layout.astro
│   ├── pages/
│   │   ├── dogs.astro
│   │   ├── jokes.astro
│   │   └── index.astro
│   ├── reason_components/
│   │   ├── Dog.re
│   │   ├── Dog.rei
│   │   ├── Joke.re
│   │   ├── Joke.rei
│   │   └── dune
│   └── reason_components_output/
│       ├── node_modules/
│       └── src/
│           └── reason_components/
│               ├── Dog.js
│               └── Joke.js
├── package.json
├── astro-reason.cfg.mjs
├── .opam
├── dune
├── dune-project
└── Makefile

No doubt, the path to the components in the promoted output folder is ugly and cumbersome, but you can remedy this by adding a path alias in a tsconfig.json or jsconfig.json file:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@components/*": ["src/reason_components_output/src/reason_components/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Promoting serverless functions

How you promote serverless functions depends on where you are deploying them. If you are using something like Netlify, where functions can be configured independently of the frontend, then promoting the serverless functions folder might be the best option.

Expanding on the Astro example above we can add serverless functions:


/
├── _build/
├── public/
├── reason_netlify_functions/
│   ├── dune
│   └── joke.re
├── reason_netlify_functions_output/
│   ├── node_modules/
│   └── reason_netlify_functions/
│       └── joke.js
├── src/
...
└── Makefile

We can then tell Netlify the location of our functions (./reason_netlify_functions_output/reason_netlify_functions) in a netlify.toml file. See this basic Astro, Reason and Netlify application for an example.

If you are using something like Vercel, where the serverless functions are expected to be in a certain folder, then it might be best to promote the whole app.

Promoting the whole app

When promoting only certain folders extra paths are created when the output JS files are copied back next to the Reason source files. If we were to do this for applications that require serverless function routes to be in an api folder then the path for that route would be long and ugly; for example, you could end up with something like api/output/reason_serverless_functions/joke/route.js. You could use some hacks to get around such a path, e.g., redirects or a .env value to shorten the path, but...

no time for that

A better and cleaner solution is to promote the whole app. What does it mean to promote the whole app? Promoting the whole app involves adding all the meta-framework files to a melange.runtime_deps stanza as dependencies so that they are copied to the _build folder and next to the output JS files. We can then promote all the meta-framework files back out of the _build folder along with the output JS.

A typical TypeScript NextJS App Router application will be structured something like:

├── README.md
├── app/
│   ├── api/
│   │   └── joke/
│   │       └── route.ts
│   ├── jokes/
│   │   └── page.tsx
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── next-env.d.ts
├── next.config.js
├── node_modules/
├── package-lock.json
├── package.json
├── postcss.config.js
├── public/
├── tailwind.config.ts
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

We can then convert it to a Reason application:


├── README.md
├── reason_app/
│   ├── api/
│   │   └── joke/
│   │       ├── dune
│   │       └── route.re
│   ├── jokes/
│   │   ├── dune
│   │   ├── page.re
│   │   └── page.rei
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   ├── page.tsx
│   └── dune
├── next-env.d.ts
├── next.config.js
├── node_modules/
├── package-lock.json
├── package.json
├── postcss.config.js
├── public/
├── tailwind.config.ts
├── tsconfig.json
├── <project_name>.opam
├── dune
├── dune-project
└── Makefile

It is important to note that we are not using a src folder (i.e., ./src/app/) for our application structure and Reason source code. We will be using the src folder eventually because it is important when promoting the output JS.

We have to rename the app folder to reason_app because an app folder at the root level will take priority over a src/app folder.

In ./app/dune we add the NextJS files to the melange.runtime_deps stanza:


(library
 (name app)
 (modes melange)
 (libraries reason-react jokes)
 (melange.runtime_deps
  favicon.ico
  globals.css
  (glob_files *.tsx))
 (preprocess
  (pps reason-react-ppx melange.ppx)))

We can then add a reason_bindings folder at the root level for any bindings we want, and when we build the app the output in the _build folder will be:

./_build/default/output/
├── reason_app/
│   ├── api/
│   │   └── joke/
│   │       └── route.js
│   ├── jokes/
│   │   └── page.js
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── reason_bindings/
└── node_modules/
Enter fullscreen mode Exit fullscreen mode

Note the node_modules folder. This node_modules folder contains the output JS for the Melange libraries used in the application. It is not the contents of the node_modules folder created from package.json.

Using dune directory-targets (add (using directory-targets 0.1) to the ./dune-project file) we can promote the output JS in the _build folder how we like. In ./dune we can add a rule for the promotion of the output code to the all important ./src folder, thus creating a typical NextJS application structure that uses ./src/app:

(rule
 (alias promote-app)
 (mode
  (promote (until-clean)))
 (deps
  (alias_rec nextjs))
 (targets
  (dir src))
 (action
  (bash
   "mkdir src && cp -r output/reason_app src/app && cp -r output/reason_bindings src && cp -r output/node_modules src")))
Enter fullscreen mode Exit fullscreen mode

alias promote_app is an alias we can use in our Makefile so that the rule is run after every build. deps (alias_rec nextjs) is a dependency on the recursive construction of our main application code which has the alias of nextjs. dir src sets the target for the promotion of the output JS as src. In the action stanza we use bash to copy output/reason_app to src/app, making it recognisable to NextJS. We also copy reason_bindings and the Melange node_modules to src so that they are at the same directory level as app, which is important for maintaining import paths defined in the application when it was built and put into the build folder. What we end up with is:

/
├── _build/
...
├── public/
├── reason_app/
├── reason_bindings/
├── src/
│   ├── app/
│   ├── node_modules/
│   └── reason_bindings/
├── package.json
├── next.config.js
...
├── <project_name>.opam
├── dune
├── dune-project
└── Makefile
Enter fullscreen mode Exit fullscreen mode

And the app should work perfectly with the NextJS dev tooling. See this basic NextJS App Router, Reason and Melange 2 application for an example.

Miscellaneous

Melange node_modules and deployment

If you want to deploy your app by connecting a Git repo to a service like Vercel or Netlify then you need to include the Melange node_modules folder in the repository. If you don't you will likely get errors about being unable to find certain Melange libraries during the build process.

If you don't want to commit the Melange node_modules folder to your repo then you can build the app locally using the Vercel/Netlify CLI and deploy using the CLI as well.

Another option is to use a CI/CD workflow to spin up a Linux container, install opam and build everything from scratch, but...

no time for that

Directives

To use the directives "use client" and "use server" in a NextJS App Router application add [@mel.config {flags: [|"--preamble", "\"use client\";"|]}]; to the top of a file.

React Server Components

React Server Components are not yet supported by ReasonReact. You can work around this by creating a standard page.tsx file. If you need a React component then you can write one in ReasonReact and import it into the page file.

Editor errors when importing components from ReasonReact

If you structure your application so that the whole app is going to be promoted out of the _build folder and you import a ReasonReact component from a .re file into a page.tsx file you will get an editor error about the import. This error occurs because you have to import the component as if the .re file was a JS component file. Once compiled the .re file will be converted to a .js file in the _build folder and it will be in the correct location for the import to work properly after promotion. This is another reason to ensure that the folder structure of the output JS in the _build folder is maintained when promoting the code.

Special characters in file names

Dune will allow some special characters in folder names but it does not allow them in file names (in my limited experience). Remix uses file name based routing and the file names can have special characters in them, e.g., concerts.$city.js. When a file name with special characters in it fails to compile we can use a Dune rule stanza to create an output JS file with special characters in it:


; ./src/routes/dune

(library
 (name app)
 (libraries reason-react routes bindings)
 (modes melange)
 (melange.runtime_deps tailwind.css concerts_city.js)
 (preprocess
  (pps reactjs-jsx-ppx melange.ppx)))

(rule
 (target concerts.$city.js)
 (deps %{project_root}/remix/app/routes/concerts_city.js)
 (action
  (copy %{deps} %{target})))

In the above dune file we add concerts_city.js to the melange.runtime_deps - this is the output JS file name created from the Reason source code file concerts_city.re. In the rule stanza we set the target as the special character containing file name we want and the dependency (deps) as the path to concerts_city.js in the _build folder. The rule then copies concerts_city.js to concerts.$city.js, which should then work perfectly in a Remix application. Here is a broken and unfinished Remix app that may be of some limited use.

Hot Module Replacement (HMR)

If you find that HMR is not working properly it might help to add a delay to the meta-framework file watcher. For NextJS you could add the following to next.config.js:

...
const nextConfig = {
  ...
  webpack: function (config, context) {
    config.watchOptions = {
      poll: 1000,
      aggregateTimeout: 300,
    };
    return config;
  },
  ...
};
...
Enter fullscreen mode Exit fullscreen mode

Ignore _build and _opam folders

A meta-frameworks file watcher will watch for any changes in any of the files and folders in root. The presence the OCaml _build and _opam folders can cause the meta-frameworks file watcher to behave in unexpected ways, so it is best to configure the meta-frameworks file watcher to ignore the _build and _opam folders. For Astro you can add the following to astro.config.mjs:

...
export default defineConfig({
  ...
  vite: {
    server: {
      watch: {
        ignored: ["**/_build/**", "**/_opam/**"]
      }
    }
  },
  ...
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Melange 2 and the capabilities of Dune allow Reason and ReasonReact to work very well with React meta-frameworks. There maybe a few aspects of the meta-framework and their development tools that you have to work around, but hopefully the information above should help to make development go smoothly.


Thank you to Javier Chávarri for the help with Dune.

Top comments (0)