Converting a JavaScript React app to a ReScript React app.

Josh Derocher-Vlk - Sep 27 '23 - - Dev Community

ReScript is "Fast, Simple, Fully Typed JavaScript from the Future". Let's take a look at how we can add it to an existing React project.

Our project

This simple counter app is one of the examples you can find in the React docs.

How does ReScript work with JavaScript?

ReScript can be dropped into an existing JS app and is a part of the JS toolchain and ecosystem. It doesn't have a different package manager so you can use NPM, Yarn, or PNPM (even Bun!).

You just install it with npm i rescript.

Getting started

We'll clone the repo, open up VSCode, and install the ReScript extension so we have syntax highlighting and code completion.

Now we can install our ReScript dependencies:



npm i rescript @rescript/react @rescript/core


Enter fullscreen mode Exit fullscreen mode

Note: this repo is a bit older and still uses React 16. We'll need to update this to React 18 so we can use the latest version of @rescript/react. We could make it work with React 16, but I think upgrading can be part of our refactoring.

Set up our ReScript config file, bsconfig.json.



{
    "name": "rescript-counter-app",
    "sources": [
        {
            "dir": "src",
            "subdirs": true
        }
    ],
    "package-specs": [
        {
            "module": "es6",
            "in-source": true
        }
    ],
    "suffix": ".bs.js",
    "bs-dependencies": [
        "@rescript/core",
        "@rescript/react"
    ],
    "bsc-flags": [
        "-open RescriptCore"
    ],
    "jsx": {
        "version": 4,
        "mode": "automatic"
    }
}


Enter fullscreen mode Exit fullscreen mode

Note: ReScript used to be known as Bucklescript, so you will see some references to bs scattered around for now.

Let's add a couple scripts to run ReScript:



// package.json
"scripts": {
  ...
  "res:dev": "rescript build -w",
  "res:build": "rescript"
}


Enter fullscreen mode Exit fullscreen mode

Here's a PR for the initial setup: https://github.com/jderochervlk/rescript-counter-app/pull/1

Where to start?

Let's first try and get the app running for local development.



npm run start


Enter fullscreen mode Exit fullscreen mode

I have an issue with the older version of react-scripts. Updating to v5 fixes it and the app is running!

Image description

I'll open up a new terminal tab and run npm run res:dev to kick off the ReScript compiler in watch mode.

With a TypeScript app we would be doing this conversion from a "breadth" first approach by changing all of the .js files to .ts files, leaving strict mode off, and slowly fix all of the any types and turn strict mode on. ReScript has a "depth" first approach where we will tackle one file at a time. We have to have all of the types in that file correct before we can compile and move onto the next file. It's usually easiest to do this by starting at the bottom of the code like small components or util functions.

The NavBar component looks like a good place to start.

Navbar

Here's the starting code:



import React from "react";

// Stateless Functional Component

const NavBar = ({ totalCounters }) => {
  return (
    <nav className="navbar navbar-light">
      <div className="navbar-brand">
        <i className="fa fa-shopping-cart fa-lg m-2" aria-hidden="true" />
        <span
          className="badge badge-pill badge-info m-2"
          style={{ width: 50, fontSize: "24px" }}
        >
          {totalCounters}
        </span>
        Items
      </div>
    </nav>
  );
};

export default NavBar;


Enter fullscreen mode Exit fullscreen mode

It's a stateless functional component, so this should be really easy. I'm going to rename it to .res and work through the compiler errors.

  • There are no imports in ReScript, every file has a module based on the filename. This means we can get rid of import React from "react";. We also delete the export.
  • there isn't const in ReScript. Everything, including functions, uses let binding. So we can change the const to let.
  • we will change the name of the NavBar function to make and add the @react.component attribute to it. This tells the compiler that this is a React component, and we don't need to give it a name because it will come from the module name (the filename).
  • The JS version uses destructured props ({ totalCounter }). ReScript uses labeled arguments so we need to change that to (~totalCounters).
  • ReScript doesn't use return. The last expression in a block is returned. So we delete return and just leave the JSX expression.
  • The aria-hidden tag isn't valid in ReScript and it needs to be changed to ariaHidden and the value needs to be boolean instead of a string: <i className="fa fa-shopping-cart fa-lg m-2" ariaHidden=true />
  • In our style tag we can't use an int, it has to be a string. Everything in ReScript can only be one type so style can't be a record of string or int, it has to just be a record of string.
  • The text that says "Items" has to be wrapped in React.string. We can't just put text here because of the strict types, we have to pass it to a function that converts a string into a type of React.element. Don't worry, this doesn't exist in our compiled code.

Great! Now it compiles. Here's what it looks like:



@react.component
let make = (~totalCounters) => {
  <nav className="navbar navbar-light">
    <div className="navbar-brand">
      <i className="fa fa-shopping-cart fa-lg m-2" ariaHidden=true />
      <span className="badge badge-pill badge-info m-2" style={{width: "50", fontSize: "24px"}}>
        {totalCounters}
      </span>
      {React.string("Items")}
    </div>
  </nav>
}


Enter fullscreen mode Exit fullscreen mode

It's not that different from our original code and a JavaScript dev should easily be able to read it.

Note: We're not putting type annotations on things because ReScript can correctly infer all of our types. Everything you see here has a type that is guaranteed to be correct. totalCounters is correctly inferred to be a React.element because that's how we use it later.

Now our React app is complaining that it can't find the navbar.js file. We just need to change the import in App.js to import { make as NavBar } from "./components/navbar.bs"; and it will compile and render as expected.

Every thing looks good so far! Here's a PR with the changes: https://github.com/jderochervlk/rescript-counter-app/pull/2

Note: When you are starting out with ReScript it is recommended to commit the compiled JavaScript along with the source ReScript code. This allows people to look at PRs and see the JS code and they should be able to contribute even if they don't fully understand everything in ReScript. It will help you and your team learn the language together.

Counter

This one will be a bit more tricky because it's a class component. ReScript only has functional components, so we'll have to refactor it. I'll do that and then convert it to ReScript.

Here's our starting refactored component


import React from "react";

function Counter({ counter, onIncrement, onDecrement, onDelete }) {
  let getBadgeClasses = () => {
    let classes = "badge m-2 badge-";
    classes += counter.value === 0 ? "warning" : "primary";
    return classes;
  };

  let formatCount = () => {
    const { value } = counter;
    return value === 0 ? "Zero" : value;
  };

  return (
    <div>
      <div className="row">
        <div className="">
          <span style={{ fontSize: 24 }} className={getBadgeClasses()}>
            {formatCount()}
          </span>
        </div>
        <div className="">
          <button
            className="btn btn-secondary"
            onClick={() => onIncrement(counter)}
          >
            <i className="fa fa-plus-circle" aria-hidden="true" />
          </button>
          <button
            className="btn btn-info m-2"
            onClick={() => onDecrement(counter)}
            disabled={counter.value === 0 ? "disabled" : ""}
          >
            <i className="fa fa-minus-circle" aria-hidden="true" />
          </button>
          <button
            className="btn btn-danger"
            onClick={() => onDelete(counter.id)}
          >
            <i className="fa fa-trash-o" aria-hidden="true" />
          </button>
        </div>
      </div>
    </div>
  );
}

export default Counter;


Enter fullscreen mode Exit fullscreen mode

getBadgeClasses

ReScript doesn't have a += operator (values are immutable) so we can just concatenate the string using ++. Before we can access counter.value we need to declare a type for it. We don't have to annotate our function with that type because the compiler will connect them together for us.



type counter = {value: int}

...

let getBadgeClasses = () => {
  "badge m-2 badge-" ++ {counter.value === 0 ? "warning" : "primary"}
}


Enter fullscreen mode Exit fullscreen mode

formatCount

We can't concatonate a string with an int like we can in JavaScript so we need to convert our value to a string first.



let formatCount = () => {
  counter.value == 0 ? "Zero" : counter.value->Int.toString
}


Enter fullscreen mode Exit fullscreen mode

Note: -> is the pipe operator. It allows us to take a value and pass it as the first argument of the next function.

The rest

Clean up our JSX and fix the types passed to DOM elements and update the import in counters.jsx and it will render and work correctly.

PR is here: https://github.com/jderochervlk/rescript-counter-app/pull/3

Counters

This one is also a class component that we need to refactor first, which just takes a minute since it doesn't have state.

We want to use the counter type from counter.res so we will add a type annotation to our prop:



let make = (~onReset, ~onIncrement, ~onDelete, ~onDecrement, ~counters: array<Counter.counter>, ~onRestart) => { ... }


Enter fullscreen mode Exit fullscreen mode

If a type comes from another module you need to usually add an annotation or Open that module so inference will work.

Since we are using counter as the shared type from the Counter module we will rename it to t, which is the normal way in ReScript to say "this is the main type of this module". It's easier to read and use Counter.t for the type instead of doing Counter.counter.

Here's the PR for Counters: https://github.com/jderochervlk/rescript-counter-app/pull/4

App.js

This is another class component we will need to refactor first. It has state and some methods to manage the state. To make this easier and avoid doing rework I will comment out the internals of those functions for now and rework each one into rescript.

Converting it over to a functional component and refactoring it to ReScript allowed us to clean up the state and state management functions to not mutate values and be more declarative.



  let (counters, setCounters) = React.useState(() => [
    {id: 1, value: 0},
    {id: 2, value: 0},
    {id: 3, value: 0},
    {id: 4, value: 0},
  ])

  let handleIncrement = counter => {
    setCounters(prev => prev->Array.map(c => c.id != counter.id ? c : {...c, value: c.value + 1}))
  }

  let handleDecrement = counter => {
    setCounters(prev => prev->Array.map(c => c.id != counter.id ? c : {...c, value: c.value - 1}))
  }

  let handleReset = () => {
    setCounters(prev =>
      prev->Array.map(c => {
        ...c,
        value: 0,
      })
    )
  }

  let handleDelete = counterId => {
    setCounters(prev => prev->Array.filter(c => c.id !== counterId))
  }


Enter fullscreen mode Exit fullscreen mode

handleReset

This function uses window.location.reload() which is a DOM api. We don't currently have any DOM bindings installed and available for ReScript. Let's go ahead and install some bindings.



open Webapi.Dom
open Location

...

let handleRestart = () => {
  location->reload
}


Enter fullscreen mode Exit fullscreen mode

Here's the PR for App.js: https://github.com/jderochervlk/rescript-counter-app/pull/5

index.js

This is what it looks like as index.res:



%%raw("import './index.css'")
%%raw("import 'bootstrap/dist/css/bootstrap.css'")
%%raw("import 'font-awesome/css/font-awesome.css'")

switch ReactDOM.querySelector("#root") {
| Some(rootElement) => {
    let root = ReactDOM.Client.createRoot(rootElement)
    ReactDOM.Client.Root.render(root, <App />)
  }
| None => ()
}


Enter fullscreen mode Exit fullscreen mode

Since we don't have a way to import .css files into ReScript we can use the %%raw expression to tell ReScript that we have a little snippet we aren't going to compiler. Here are some docs on this.

The way this CRA works it needs an index.js file, so index.bs.js won't work. Thankfully we can just update bsconfig.js and it will change all the extensions for us.



{
  ...
  "suffix": ".js",
  ...
}


Enter fullscreen mode Exit fullscreen mode

Here's our final PR: https://github.com/jderochervlk/rescript-counter-app/pull/6

Wrapping up

We did it!

Image description

Now our app is fully typed and when saving a ReScript file the compiler updates in under 110 milliseconds so we didn't add any delays to our build process. If we run npx rescript clean and then npx rescript to do a fresh build from scratch it takes about 2 seconds.

We were able to break out the app and switch to ReScript slowly over multiple PRs and we didn't make any other changes to our application or tooling. This is still a CRA and we still use the standard npm run start to run it, and we can also eject it if we want to customize the configs later if we need to.

Feel free to ask any questions in the comments!

. . . . . . . . .