How to render data in a ReScript React app using fetch, promises, and safe types.

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

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"
  />



Enter fullscreen mode Exit fullscreen mode


// src/Button.resi
let make: JsxDOM.domProps => Jsx.element


Enter fullscreen mode Exit fullscreen mode

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>
}


Enter fullscreen mode Exit fullscreen mode

And double check that you have a src/App.resi file.



@react.component
let make: unit => Jsx.element


Enter fullscreen mode Exit fullscreen mode

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.

A title that says

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>


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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)


Enter fullscreen mode Exit fullscreen mode

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 file bacon.res. ReScript doesn't have imports, so to access the types and values in bacon.res you use Bacon.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"])


Enter fullscreen mode Exit fullscreen mode

You will see a compiler error.

Image description

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"
],


Enter fullscreen mode Exit fullscreen mode

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)
  }
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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
  ],


Enter fullscreen mode Exit fullscreen mode

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)
}


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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>
}


Enter fullscreen mode Exit fullscreen mode

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>
}


Enter fullscreen mode Exit fullscreen mode

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>
}


Enter fullscreen mode Exit fullscreen mode

We can see 3 console logs happening!

Image description

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
  }


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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>
}


Enter fullscreen mode Exit fullscreen mode

Finally let's attach the handleBacon function to the onClick of our Button.



<Button
  onClick={_ => {
    let _ = handleBacon()
  }}>
  {"refresh data"->React.string}
</Button>


Enter fullscreen mode Exit fullscreen mode

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>
}


Enter fullscreen mode Exit fullscreen mode

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, and coffee!

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)


Enter fullscreen mode Exit fullscreen mode

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(_)


Enter fullscreen mode Exit fullscreen mode

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!

My other ReScript articles

. . . . . . . . .