Photo by James Kern on Unsplash.
ReScript is "Fast, Simple, Fully Typed JavaScript from the Future" that has stronger types than TypeScript, a blazing fast compiler, and only includes the good parts of JavaScript.
Let's take a look at how we can create a React app that will fetch some strings from an API and render each string in a paragraph. The strings will be random from Bacon Ipsum. We'll include a loading state, error handling, and a button that will allow the user to reload the strings.
We'll be using ReScript's Variant types to represent the state of our data, which can be "loading", "error", or "data".
We will also be using an opaque type and a parsing library to enforce some logic around what we consider to be valid data.
Set up
I'll be using Vite to build this out. It's focus on speed makes it a great companion to ReScript. I have a guide with details on how to set up our basic ReScript, React, Vite, and TailwindCSS app here.
If you used the generator you should already have a src/Button.res
component. If you did it manually go ahead and add these two files.
// src/Button.res
// Styling from https://tailwind-elements.com/docs/standard/components/buttons/
let make = props =>
<button
{...props}
className="inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out"
/>
// src/Button.resi
let make: JsxDOM.domProps => Jsx.element
Replace the contents of src/App.res
with this:
@react.component
let make = () => {
<div className="p-6">
<h1 className="text-3xl font-semibold mb-5"> {"Bacon Ipsum"->React.string} </h1>
<Button> {"refresh data"->React.string} </Button>
</div>
}
And double check that you have a src/App.resi
file.
@react.component
let make: unit => Jsx.element
Kick off the Vite dev server with npm run dev
and if you are using VSCode with the ReScript extension it should ask you if you want to start the ReScript build server, otherwise run npm run res:dev
in another terminal to start ReScript.
You should now be able to see a basic site with blazing fast HMR when you save a file.
Planning out our data with types
Before we start writing any features let's plan out our data and business logic with types. If you want to learn more about how to do Domain Driven Design in a functional language check out this talk and book from Scott Wlaschin. He's using F#, but ReScript is also in the ML family of languages so we are able to do the same thing here.
What is our data?
In our application we have randomized strings we get from Bacon Ipsum. We will refer to these strings as a bacon
. Our application will render bacon
to the user, but we want to verify that we're not just allowing any array of strings to be rendered when we expect bacon
. This can cause bugs later once our application scales and we have 30 developers, 2 product managers, and 100,000 lines of code.
We know that bacon
is always external and that we have to request it from an API. This means that it could be Loading
, have an Error
, or have Data
.
Let's use the strong type system of ReScript to help us define bacon
in a way that the compiler will help us follow our own business logic and prevent bugs later.
Create our types
Let's start by creating a type for bacon
. In a new file called bacon.res
we will add a type t
that is an alias to array<string>
. t
is the standard name for a module's main type. Here we have a module Bacon
that is generated from the file bacon.res
and the main type is Bacon.t
.
type t = array<string>
We'll make this opaque later, but for now let's keep adding types.
We will now make a variant type for bacon
to represent the possible states of our data.
type t = array<string>
type error = string
type bacon =
| Loading // loading has no value
| Error(error)
| Data(t) // and our data contains our internal type t
Those are our main two types we will be working with. Let's create a bacon.resi
file. This is ReScript's interface file and it allows us to define types for a module. Having an interface file speeds up the already fast compiler and it allows us to keep some things in our module private.
// src/bacon.resi
type t
type error
type bacon =
| Loading
| Error(error)
| Data(t)
Notice how it looks almost identical to our implementation file, but we didn't alias t
to array<string>
or error
to string
. This now means that you can't just make anything be Bacon.Data
or a Bacon.Error
.
Note:
Bacon
is now a module in our application created from the filebacon.res
. ReScript doesn't have imports, so to access the types and values inbacon.res
you useBacon.Data
.
To test this out let's go into App.res
and try and add the following to the bottom of the file.
let bacon = Bacon.Data(["foo", "bar"])
You will see a compiler error.
To anything outside of the Bacon
module it's unknown what the type is for Bacon.t
. This means we just can't put any value in there. The Bacon
module will need a way for us to create something that is Bacon.t
.
Smart constructor
In order to take in an array<string>
and turn it into valid Bacon.t
we will be using a smart constructor which is a function that looks at our unknown input and has a runtime validation to make sure the data is correct.
We'll be using rescript-struct as our parsing library.
Note: the links are for v4 which is not the latest version. v5 depends on ReScript v11 which I am not using yet. V4 and V5 have slightly different APIs, but the concept is the same.
Install it with npm install rescript-struct@v4
and add it to bsconfig.json
.
"bs-dependencies": [
"@rescript/react",
"@rescript/core",
"rescript-struct"
],
"bsc-flags": [
"-open RescriptCore",
"-open RescriptStruct"
],
We'll add this to bacon.res
/**
We are creating a struct that validates that the input is what we expect it to be. This check is done at run time and the function generates types that we can use when compiling.
Check the rescript-struct docs for more details.
*/
let baconStruct = S.array(S.string())
let constructBacon = x => {
// we use parseAnyWith because we the data coming from our fetch request will be of an unknown type
let val = x->S.parseAnyWith(baconStruct)
// val will be a Result, which is either OK or an Error
// we can map this to internal values of Bacon.Data or Bacon.Error
switch val {
| Ok(a) => Data(a)
| Error(e) => Error(e->S.Error.toString)
}
}
Fetch our data and assign it to the correct type
Now that we have our types defined and a way to verify the incoming data is correct, let's go ahead and fetch some data.
We'll need to install a ReScript library for DOM bindings including fetch
.
npm install rescript-webapi
Add it to bsconfig.json
:
"bs-dependencies": [
"@rescript/react",
"@rescript/core",
"rescript-struct",
"rescript-webapi" // new
],
"bsc-flags": [
"-open RescriptCore",
"-open RescriptStruct",
"-open Webapi", // new
"-open Promise" // new
],
Now we can add a fetch function to bacon.res
:
let url = "https://baconipsum.com/api/?type=meat-and-filler"
let fetch = () => {
// instead of .then we pipe to the then function for the next step
Fetch.fetch(url)->then(res =>
// we can switch on res.ok to handle a success or failure
switch res->Fetch.Response.ok {
// if the response is ok we can parse the json and use constructBacon to validate that the data is correct
| true => res->Fetch.Response.json->then(res => res->constructBacon->resolve) // every Promise in ReScript has to return another Promise
| false =>
// if the response isn't ok we create an Error string with the status and statusText
Error(
`${res->Fetch.Response.status->Int.toString}: ${res->Fetch.Response.statusText}`,
)->resolve
}
)
// and if an unknown error occurs we use Promise.catch to hande it
->catch(_ => Error("Something went wrong")->resolve)
}
We want that function to be available outside of the Bacon
module so let's add it to bacon.resi
:
type t
type error
type bacon =
| Loading
| Error(error)
| Data(t)
let fetch: unit => Promise.t<bacon>
Nice! Hopefully you can start to see how the .resi
file allows us to keep everything that we mean to be consumed from the outside in a clean and easy to understand place. We can do whatever we want in the implementation file, but the outside world can only use what we allow them to.
Fetch the data on page load
Now we have all of plumbing set up for the Bacon
module we can start adding it to our React page in App.res
.
Create a local state to track our bacon data:
@react.component
let make = () => {
// the default state will be Loading
let (bacon, setBacon) = React.useState(() => Bacon.Loading) // new
<div className="p-6">
<h1 className="text-3xl font-semibold mb-5"> {"Bacon Ipsum"->React.string} </h1>
<Button> {"refresh data"->React.string} </Button>
</div>
}
We'll add in a useEffect0
to call a side effect with an empty dependency array in our main App.res
component. This will call our Bacon.fetch
only once when the component first mounts.
@react.component
let make = () => {
let (bacon, setBacon) = React.useState(() => Bacon.Loading)
/* new */
let handleBacon = () => Bacon.fetch()->then(t => setBacon(_ => t)->resolve)
/* new */
React.useEffect0(() => {
let _ = handleBacon() // we assign this to _ since we aren't returning it
None // we have nothing to clean up so we return None
})
<div className="p-6">
<h1 className="text-3xl font-semibold mb-5"> {"Bacon Ipsum"->React.string} </h1>
<Button> {"refresh data"->React.string} </Button>
</div>
}
Let's add in a quick console log to make sure we are fetching the data.
@react.component
let make = () => {
let (bacon, setBacon) = React.useState(() => Bacon.Loading)
let handleBacon = () => Bacon.fetch()->then(t => setBacon(_ => t)->resolve)
React.useEffect0(() => {
let _ = handleBacon()
None
})
/* new */
React.useEffect1(() => {
Console.log(bacon)
None
}, [bacon])
<div className="p-6">
<h1 className="text-3xl font-semibold mb-5"> {"Bacon Ipsum"->React.string} </h1>
<Button> {"refresh data"->React.string} </Button>
</div>
}
We can see 3 console logs happening!
Once for the blank loading state, and two times from our useEffect
hook in React Strict mode.
Let's go ahead and render the data with pattern matching:
Let's add a render function to bacon.res
to handle each possible state.
let render = bacon =>
switch bacon {
| Loading => <p className="my-3"> {React.string("loading...")} </p>
| Error(err) => <p className="my-3"> {React.string(err)} </p>
| Data(data) => data->Array.map(text => <p className="my-3"> {React.string(text)} </p>)->React.array
}
Add that function to our bacon.resi
file:
type t
type error
type bacon =
| Loading
| Error(error)
| Data(t)
let fetch: unit => Promise.t<bacon>
let render: bacon => React.element
And we can render it now in our app:
// src/App.res
@react.component
let make = () => {
let (bacon, setBacon) = React.useState(() => Bacon.Loading)
let handleBacon = () => Bacon.fetch()->then(t => setBacon(_ => t)->resolve)
React.useEffect0(() => {
let _ = handleBacon()
None
})
<div className="p-6">
<h1 className="text-3xl font-semibold mb-5"> {"Bacon Ipsum"->React.string} </h1>
<Button> {"refresh data"->React.string} </Button>
// new
{bacon->Bacon.render}
</div>
}
Finally let's attach the handleBacon
function to the onClick
of our Button
.
<Button
onClick={_ => {
let _ = handleBacon()
}}>
{"refresh data"->React.string}
</Button>
App.res
will now look like this:
// src/App.res
@react.component
let make = () => {
let (bacon, setBacon) = React.useState(() => Bacon.Loading)
let handleBacon = () => Bacon.fetch()->then(t => setBacon(_ => t)->resolve)
React.useEffect0(() => {
let _ = handleBacon()
None
})
<div className="p-6">
<h1 className="text-3xl font-semibold mb-5"> {"Bacon Ipsum"->React.string} </h1>
<Button
onClick={_ => {
let _ = handleBacon()
}}>
{"refresh data"->React.string}
</Button>
{bacon->Bacon.render}
</div>
}
Wrapping up
We now have a simple application that fetches data from an external source, verifies that data is the shape we expect, and with types can represent the possible states of our data.
Why do we need these opaque and variant types?
In our example bacon
is means more than just a bunch of strings. bacon
is a list of strings that comes from a certain API. There is no other way to create bacon
. This is incredibly powerful because we can now rely on the compiler and type system to know the source of this list of strings. We have given extra meaning to the data.
In 6 months or a year if another developer has to work on this code, or worse we have to come back to it and remember what we did, we have set up a system that enforces data flow and will help us catch bugs.
Here are some features we will have to add to our app over the next few months. As we add these features our team will be hiring 5 new developers to help us implement all of these. I'll let you imagine how this would go in a traditional JavaScript application compared to our
- Add a new page and api for
eggs
- Log anytime a user sees
bacon
- Show a special pop up to users who are seeing
eggs
- Capitalize every first word in
bacon
- Add a new page and api for
sausage
- Show a log in prompt for users who are seeing
sausage
-
sausage
now has two api responses depending on if the user is logged in or not - if a logged in user sees
bacon
we want to send an email to that user - business is going great! We need to add pages and apis for
fruit
,juice
, andcoffee
!
And so on.
In our ReScript app we have strong guarantees that we can't accidentally use data with functions that might have unintended side effects. We don't want to log the wrong thing to our database, or show a popup on the wrong page. Sure, we can do all of this without types, but eventually something will be missed or forgotten.
Imagine that we have a dozen functions that do something with bacon
and we have to add a new variant type for AuthenticatedData
:
type bacon =
| Loading
| Error(error)
| Data(t)
| AuthenticatedData(t)
In any function that does pattern matching with bacon
we will get a warning from the compiler:
You forgot to handle a possible case here, for example: AuthenticatedData(_)
We can now go through each function and update the switch to handle the new case. We can even configure the ReScript compiler to error instead of warn for this to prevent us from shipping it to production.
Codesandbox
Here's the app running in Codesandbox if you want to take a look around.
Questions?
Feel free to ask anything in the comments!
Top comments (0)