Converting a JavaScript React app to a ReScript React app.
Josh Derocher-Vlk
Posted on September 27, 2023
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
- rescript: The language compiler
- @rescript/react: ReScript bindings to React and support for JSX
- @rescript/core: The ReScript standard library
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"
}
}
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"
}
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
I have an issue with the older version of react-scripts
. Updating to v5 fixes it and the app is running!
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;
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, useslet
binding. So we can change theconst
tolet
. - we will change the name of the
NavBar
function tomake
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 deletereturn
and just leave the JSX expression. - The
aria-hidden
tag isn't valid in ReScript and it needs to be changed toariaHidden
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 ofReact.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>
}
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 aReact.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;
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"}
}
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
}
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) => { ... }
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))
}
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
}
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 => ()
}
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",
...
}
Here's our final PR: https://github.com/jderochervlk/rescript-counter-app/pull/6
Wrapping up
We did it!
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!
Posted on September 27, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.