Skip to content

Public repo for the "Write maintainable React applications using OCaml" React Alicante workshop

License

Notifications You must be signed in to change notification settings

ahrefs/react-alicante-workshop

Repository files navigation

React Alicante workshop

The project we will be working on is a feed reader that picks data from any GitHub user profile and shows the latest actions in an ordered list. Along the way, we will learn about OCaml, how to write React components using ReasonReact, exploring Melange libraries, data fetching, and more. Let's get started!

Step 0 (to be completed before the workshop)

In order to start working on the project, you will need:

0.1: Node and npm

While Melange provides a way to compile OCaml code into JavaScript, we will still be using JavaScript tools like esbuild, or download npm libraries like react.

To download Node and npm, follow the instructions from the official website. Note that while other package managers and runtimes might work, we have not tested the workshop code with them, so we recommend to stick with these two.

0.2: opam

ℹ️ Windows users only: Ensure you execute the following commands within WSL (Windows Subsystem for Linux). Follow these steps to set up WSL.

We need opam, the OCaml Package Manager. There are many ways to install it depending on your platform, but let's go with the simplest method:

bash -c "sh <(curl -fsSL https://raw.githubusercontent.com/ocaml/opam/master/shell/install.sh)"
opam init

While opam init is running, it will prompt you with something like

Do you want opam to modify ~/.zshrc? [N/y/f]

Type y to agree.

Warning

If it asks

Do you want opam to modify ~/.profile? [N/y/f]

You should enter f and then enter ~/.bashrc as the file to be modified. There's a bug when installing the shell hook when running Bash or Bourne shell.

Verify opam installation

After the installation completes, run

opam switch

You should see something like this:

#  switch                                                            compiler                                             description
→  /Users/j/Development/github/react-alicante-workshop               ocaml-base-compiler.5.2.0,ocaml-options-vanilla.1    ocaml-base-compiler = 5.2.0 | ocaml-system = 5.2.0

[NOTE] Current switch has been selected based on the current directory.
       The current global system switch is 5.2.0.

If you see this message at the bottom, then the shell hook wasn't installed correctly:

[WARNING] The environment is not in sync with the current switch.
          You should run: eval $(opam env)

To get rid of the warning, you should run setup again:

opam init --reinit

Then follow the instructions for the warning section above.

0.3: Clone the repository

From the terminal:

git clone https://github.com/ahrefs/react-alicante-workshop
cd react-alicante-workshop

0.4: Install all dependencies

Proceed to run npm run init in order to download the workshop required packages from both npm and opam repositories.

Important

This process will download, build and install the OCaml compiler, so it can take at least 5 minutes or more, depending on the laptop and network capacity.

Step 0 completion check

Once all dependencies are installed, you should be able to run npm run build successfully.

If you run into any issues, don't fret. We will have a dedicated time at the beginning of the workshop to make sure everyone has the working environment correctly set up.

Step 1: Setting up the editor environment

1.1: Install VSCode

We will be using VSCode, which you can install from https://code.visualstudio.com/Download.

ℹ️ Windows users only: Install the WSL extension for VSCode.

1.2: Install OCaml platform extension

Install the OCaml platform extension.

1.3: Select the workshop sandbox

At the bottom of the VSCode window, you should see opam(react-alicante-workshop):

VSCode OCaml selected sandbox

If that's not the case, click on that 📦 button, and select react-alicante-workshop from the list that will appear:

VSCode OCaml sandbox selector

Step 1 completion check

To make sure everything is working correctly, you should be able to hover over querySelector in App.re and see its type definition: string => option(Dom.element). If that's the case, go to definition and all other editor features should work. We're now ready to start coding!

Step 2: Our first ReasonReact component

2.1: Development commands

To keep things organized while working on the project, we'll use two separate terminals:

  • Terminal 1: Run npm run watch to monitor the build process and catch any OCaml build errors.
  • Terminal 2: Run npm run serve to start a local web server using esbuild.

With both commands running, you should be able to access the workshop's local website at http://localhost:8080/. Now, we can start improving the code.

2.2: OCaml modules

OCaml organizes code into modules. Each file is automatically a module, and they can also contain nested modules inside. To see this in action, we’ll move the code for the welcoming message into a new file, Hello.re.

To use the code in Hello.re from the main App.re, we will have to create a new component. In ReasonReact, components are just OCaml modules with a make function annotated with the @react.component attribute.

Hint: In order to create this new component, take the h1 and h2 from App.re, and move them to a new file Hello.re wrapping them with a fragment <> and a make function. Then use the new component from App using JSX: <Hello />.

Step 2 completion check

We only did a small refactor, so the build tab running npm run build should show no errors, and the local page under http://localhost:8080/ should look like it did before starting this step.

Step 3: Modeling data

To build our GitHub activity feed reader, we'll eventually fetch data from this endpoint: https://gh-feed.vercel.app/api.

This endpoint doesn’t require API keys and works as follows:

  • It accepts two query parameters: user (a string) and page (an int). For example: https://gh-feed.vercel.app/api?user=jchavarri&page=1
  • It returns an object (let's call it feed) with a key entries, which is an array of feed entries. Each entry has the following structure:
    • id: The id of the entry (string).
    • content: The content of the entry, contains HTML as a string (optional).
    • links: An array of associated links, each with:
      • href: The URL the link points to (string).
      • title: The link title or description (string).
    • title: The entry title (string).
    • updated: A timestamp of the last update (float).

Note

There are other keys in the returned data, but we will ignore them for simplicity.

In this step, we’ll define some OCaml types to represent this data in our application. Refer to the Reason documentation for details:

We will need:

  • A type link (i.e. type link = ...)
  • A type entry
  • A type feed

Tip

Create a new file Feed.re to keep these type definitions separate from the UI code. As we saw before, we are able to use its values from other modules by namespacing it, e.g. Feed.foo.

Step 3 completion check

Ensure your application builds successfully (Success in the npm run watch terminal). We may need to adjust these types later when we decode the data.

Step 4: Decoding data (the hard way)

Once you have these types defined, we will be adding some code to take the values from the JSON and decode them into values of the types we have defined.

First, install the melange-json library:

opam install melange-json

Then, modify the libraries field in the src/dune file to include melange-json:

(libraries melange-json reason-react)

Now we’re ready to decode JSON into our types. Add a Decode module inside Feed.re with the following code:

module Decode = {
  let link = json =>
    Json.Decode.{
      href: json |> field("href", string),
      title: json |> field("title", string),
    };
  let entry = json =>
    Json.Decode.{
      content: json |> optional(field("content", string)),
      id: json |> field("id", string),
      links: json |> field("links", array(link)),
      title: json |> field("title", string),
      updated: json |> field("updated", float),
    };

  let feed = json =>
    Json.Decode.{entries: json |> field("entries", array(entry))};
};

let data = {| {
  "entries": [
    {
      "content": "<div>Hello</div>",
      "id": "abcd1234",
      "links": [
        {
          "title": "",
          "href": "https://github.com/melange-community/melange-json"
        }
      ],
      "title": "jchavarri starred melange-community/melange-json",
      "updated": 1723639727000.0
    }
  ]
} |};

let demoFeed =
  try(Some(data |> Json.parseOrRaise |> Decode.feed)) {
  | Json.Decode.DecodeError(msg) =>
    Js.log(msg);
    None;
  };

This code demonstrates several key features:

  • The pipe operator |> sends the value on its right as the last argument to the function on the left, enabling clean, step-by-step data processing.
  • Local module opens like Json.Decode.{ ... } let us simplify the code by avoiding repetitive module prefixes.
  • Exception handling uses try similar to JavaScript, but with pattern matching for the catch part.
  • To ensure consistent return types in the exception handling, we leverage optional types.

Step 4 completion check

Add this debug line at the bottom of App.re:

Js.log(Feed.demoFeed)

When you open the browser to view the page, check the console for the logged content. You should see something like this:

Console log - printing some runtime values

Next, we’ll explore an easier way to decode data from JSON.

Step 5: Decoding data (the easy way)

Manually writing the Decode module, as we did earlier, might not be the best use of our time. This kind of code can be automatically derived from the type definitions, and it's easy to introduce subtle mistakes that lead to runtime errors.

Fortunately, OCaml provides a mechanism for generating new code from existing code, known as "preprocessor extensions" or PPXs.

OCaml allows us to decorate code with attributes, which look like this: [@foo]. These attributes can be attached to expressions, functions, or types to trigger specific code transformations. For example:

[@foo]
let x = 1;

Or, with a type definition:

[@foo]
type t = {
  name: string,
  age: int,
};

PPXs will pick up this attribute at compilation time and make syntactic modifications to the code. You can think of PPXs as similar to Babel plugins in JavaScript, which perform transformations during the build process.

If you want to read more about PPXs, this article provides a great introduction.

Tip

You might have noticed that these attributes look similar to the ones used to define components. That's right! ReasonReact is also a PPX, and decorators like [@react.component] are used to generate additional code automatically.

To generate our decoders automatically, we will use the PPX included with melange-json, which we installed in the previous step.

First, modify the preprocess field in the src/dune file to include melange-json.ppx:

 (preprocess
  (pps melange.ppx melange-json.ppx reason-react-ppx))

Then, annotate all types in Feed.re with [@deriving json], for example:

[@deriving json]
type link = {
  href: string,
  title: string,
};

Next, we need to bring in the functions that decode primitive types like string or int, such as string_of_json. Since there are quite a few of these functions, it’s practical to open the module and make all its functions available within the scope of the Feed module. You can do this by adding this line at the top of Feed.re:

open Ppx_deriving_json_runtime.Primitives;

Warning

In most cases, open should not be used at the top level of a module. It’s usually better to use a local open which limits the scope of the opened module’s functions to a specific function or submodule.

Now, you can remove the entire Decode module, as conversion functions like feed_to_json and feed_of_json are generated automatically by the PPX.

Finally, modify the demoFeed function to use the newly generated function:

let demoFeed =
  try(Some(data |> Json.parseOrRaise |> feed_of_json)) {
  | Json.Decode.DecodeError(msg) =>
    Js.log(msg);
    None;
  };

Step 5 completion check

We refactored the code to remove some manual decoding logic and introduced the melange-json PPX, but the behavior remains unchanged. Open the browser console to confirm that our debugging object is still displayed:

Console log - printing some runtime values

Step 6: Fetching data

In this step, we'll focus on fetching data from the GitHub feed API using the melange-fetch library. This will set the foundation for processing and displaying the data in the next step.

6.1: Installing melange-fetch

First, we need to install the melange-fetch package, which provides bindings for the Fetch API in OCaml.

Note

Bindings refer to the interfaces that allow OCaml code to interact with JavaScript APIs. They act as a bridge, enabling you to call JavaScript functions and use JavaScript objects directly within your OCaml code, as if they were native OCaml functions or types. You can write bindings manually yourself, or import bindings from existing libraries, like we are doing here with melange-fetch.

Run the following command in your terminal:

opam install melange-fetch

Remember to add "melange-fetch" to the depends field in your .opam file as well:

depends: [
  ...
  "melange-fetch"
]

6.2: Adding melange-fetch to Dune

Next, we need to tell Dune to include this library in our project. Open the src/dune file and add melange-fetch to the libraries field:

(libraries melange-fetch melange-json reason-react)

6.3: Fetching data from the API

Now that we have everything set up, let's fetch data from the feed API. Here’s how you can use melange-fetch to make a request inside an effect, you can add this for now in the App.re component:

module P = Js.Promise;
React.useEffect0(() => {
  Fetch.fetch("https://gh-feed.vercel.app/api?user=jchavarri&page=1")
  |> P.then_(Fetch.Response.text)
  |> P.then_(text => Js.log(text) |> P.resolve)
  |> ignore;
  None;
});

This snippet does the following:

  • module P = Js.Promise: Adds a module alias P to the Melange API module Js.Promise.
  • React.useEffect0: Runs the fetch operation when the component mounts. The useEffect0 function is a binding defined in reason-react.
  • Fetch.fetch(url): Initiates a GET request to the specified URL.
  • P.then_(Fetch.Response.text): Converts the response into a text string.
  • P.then_(text => Js.log(text) |> P.resolve): Logs the response text to the console.
  • ignore: Discards the final promise result since we're only interested in side effects.

Tip

Chaining promises with then_ allows you to handle asynchronous code in a structured way, similar to how you'd do it in JavaScript. We will see later on a different way to handle asynchronous code, that is closer to async/await.

6.4: Step 6 completion check

Run your application to ensure that the data is being fetched correctly. Check your console to see the printed data. If everything is working, you should see the raw JSON data in your console.

If everything works well, now it's a good time to clean up some of the previous prototyping code that we are not using anymore: Feed.data, Feed.demoFeed and the Js.log(Feed.feed_of_json); line at the end of App.re.

Step 7: Displaying data

Now that we have successfully fetched the data, let’s move on to decoding it and displaying it in the UI.

7.1: Decoding the fetched data

We need to decode the raw data using our feed_of_json function. First of all, we are going to define a new variant type to specify the state of the UI:

type loadingStatus =
  | Loading
  | Loaded(result(Feed.feed, string));

This type uses a few things:

  • Variant types, similar to enums
  • Result type, which is a predefined variant with two options: Ok and Error
  • In this case, the "Ok" part is the feed type we defined in the previous step, and the error type is just a string

Now we can adapt the effect in App.re to use the new type. We will also be adding a useState hook to store the data loading state:

/* inside the App component `make` function */
    let (data, setData) = React.useState(() => Loading);
    React.useEffect0(() => {
      Js.Promise.(
        Fetch.fetch("https://gh-feed.vercel.app/api?user=jchavarri&page=1")
        |> then_(Fetch.Response.text)
        |> then_(text =>
             {
               let data =
                 try(Ok(text |> Json.parseOrRaise |> Feed.feed_of_json)) {
                 | Json.Decode.DecodeError(msg) =>
                   Js.Console.error(msg);
                   Error("Failed to decode: " ++ msg);
                 };
               setData(_ => Loaded(data));
             }
             |> resolve
           )
      )
      |> ignore;
      None;
    });

7.2: Rendering the data in the UI

With the data decoded, it’s time to display it in the UI. Update your React component to process the result and render the feed:

  {switch (data) {
    | Loading => <div> {React.string("Loading...")} </div>
    | Loaded(Error(msg)) => <div> {React.string(msg)} </div>
    | Loaded(Ok(feed)) =>
      <div>
        <h1> {React.string("GitHub Feed")} </h1>
        <ul>
          {feed.entries
          |> Array.map((entry: Feed.entry) =>
                <li key={entry.id}>
                  <h2> {React.string(entry.title)} </h2>
                </li>
              )
          |> React.array}
        </ul>
      </div>
    }}

Note how we leverage pattern matching (switch) together with variants to map the data state directly to the UI.

Also, unlike in JavaScript, plain strings cannot be used directly as React elements in ReasonReact; they need to be converted using React.string.

Lastly, we use specific type annotations in Array.map (e.g., Feed.link) to assist the compiler in inferring the correct type within the callback. This isn’t always necessary, but it’s often required in mapping functions due to the way type inference and piping work in OCaml.

7.3: Step 7 completion check

Reload your application, and you should now see the GitHub feed displayed on the page. The data is fetched, decoded, and rendered dynamically. If everything works, the console should be free of errors, and the feed should be visible.

Can you try updating the rendering function to also show entry.content and entry.links?

Tip

You might need to use switch and the dangerouslySetInnerHTML prop for entry.content, and Array.map for entry.links.

Step 8: Using input fields to get data for any user

In this step, we'll enhance our app by letting users enter a GitHub username to fetch and display their activity feed.

To achieve this, we'll need to make a few updates to our app: add an input for the username, store the username, make the request to get the user's feed and make sure the UI gets updated.

Let's start: we will add a new state hook to store the username. We can use React.useState, which we have already seen before.

Then, we will add an input field to capture the GitHub username. Let's use the onKeyDown event to listen for the Enter key press, and add a label for clarity. We also have to ensure that the input value is bound to the username state:

<div>
  <label htmlFor="username-input"> {React.string("Username:")} </label>
  <input
    id="username-input"
    value=username
    placeholder="Enter GitHub username"
    onChange={event => {
      setUsername(event->React.Event.Form.target##value)
    }}
    onKeyDown={event => {
      let enterKey = 13;
      if (React.Event.Keyboard.keyCode(event) == enterKey) {
        setData(_ => Loading);
        fetchFeed(username);
      };
    }}
  />
</div>

Finally, we have to modify our data fetching logic to get data for the current user. This will involve the following steps:

  • Remove the previous effect and move its content to a new function fetchFeed, which will take a username of type string and perform the network request to get data for that user. You'll need to dynamically insert the username into the URL using the string concatenation operator ++ (see the syntax cheatsheet).
  • To get the initial data when the component first mounts, create a new effect that runs at initialization (useEffect0) and calls fetchFeed(username):
React.useEffect0(() => {
  fetchFeed(username);
  None;
});

Step 8 completion check

After this step is completed, we should be able to type a valid username like xavierleroy and see their most recent activity on GitHub appearing in the feed.

Don't worry about data validation or error handling for now, we will fix that in the next step.

Step 9: Validating data with abstract modules, handling edge cases and errors

9.1: Validate usernames

Right now, we can enter any kind of string in our input, but GitHub only allows usernames with specific rules:

  • Max length should be 39 characters
  • Can only contain alphanumeric characters and dashes -
  • Can't start or finish with dash -

Let's add some logic to validate the username. As the App.re module is getting large already, let's create a new module Username.re. In this module, we will define the following:

  • type t = string;
  • A function make that takes a string and returns a result value:
let make = (username: string) => {
  let re = [%re "/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/"];
  Js.Re.test(~str=username, re) ? Ok(username) : Error();
};
  • A toString function that converts Username.t back to a string.

To make sure we can't use any string as validated username incorrectly, we will make the type t abstract, In order to do so, we will add an interface file. These files define how the module types will be visible from the outside.

So let's create a new file Username.rei with this content:

type t;

let make: string => result(t, unit);

let toString: t => string;

Note how we are leaving t without a definition, this makes the type abstract. This way, every time we need to use a GitHub username in our app, we can use Username.t as a type, and this will guarantee that the string has gone through the validation before being used in the function.

To use the new type, we will modify the existing App component.

First of all, we will replace loadingStatus with another type that reflects all the potential states that our component can be in. We need to store the username as we did before, and we also want to track all potential errors, so we will define the state as a record, with two fields username and step. The latter will define the steps of our UI component, as if it was modeled as a state machine:

type step =
  | Idle /* There is no request going on, and no data to be shown. In this case, we will just show instructions to proceed with the request. */
  | Loading /* A request is currently taking place to fetch the feed. */
  | InvalidUsername /* The entered username is not a valid GitHub ID. */
  | Loaded(result(Feed.feed, string)); /* A request has finished, its result is contained inside the variant value itself. */

type state = {
  username: string,
  step,
};

After this, we will replace the two state hooks that were using for data and username with a single one that handles this new state:

  let (state, setState) =
    React.useState(() => {username: "jchavarri", step: Idle});

Let's modify fetchFeed to accept a value of type Username.t rather than string. This ensures that no requests are made with invalid usernames. Essentially, this involves replacing username with Username.toString(username) to convert it back to a string when constructing the URL.

Now, let's start thinking which parts of our component will modify the state:

  • When we call fetchFeed, we have to set step to Loading at the beginning of the function (setState(state => {...state, step: Loading});), and Loaded when the request has finished (setState(state => {...state, step: Loaded(data)}))
  • When the input value changes, we will go back to step Idle:
  onChange={event => {
    setState(_ =>
      {username: event->React.Event.Form.target##value, step: Idle}
    )
  }}
  • We validate the username on Enter to ensure only valid requests are sent. If the name is valid, we can just call fetchFeed, but if it's invalid, we will set the step to InvalidUsername:
  onKeyDown={event => {
    let enterKey = 13;
    if (React.Event.Keyboard.keyCode(event) == enterKey) {
      switch (Username.make(state.username)) {
      | Ok(username) => fetchFeed(username)
      | Error () =>
        setState(state => {...state, step: InvalidUsername})
      };
    };
  }}

To continue, we have to adapt the effect to check the username is valid:

  React.useEffect0(() => {
    switch (Username.make(state.username)) {
    | Ok(username) => fetchFeed(username)
    | Error () =>
      Js.Exn.raiseError(
        "Initial username passed to React.useState is invalid",
      )
    };
    None;
  });

We can modify the switch in the JSX code to use the new state, so instead of checking data we check for state.step:

  {switch (state.step) {
    | InvalidUsername => <div> {React.string("Invalid username")} </div>
    | Idle =>
      <div>
        {React.string(
          "Press the \"Enter\" key to confirm the username selection.",
        )}
      </div>
    | Loading => <div> {React.string("Loading...")} </div>
    | Loaded(Error(msg)) => <div> {React.string(msg)} </div>
    | Loaded(Ok(feed)) => ...
  }}

Finally, we just need to change the input's value prop from value=username to value={state.username}.

9.2: Handle edge cases and errors

To improve the user experience, we'll add messages for the following cases:

  • when there are errors returned by the server
  • or when a valid user has an empty feed

For this, we will have to modify App component in a couple of places.

The fetchFeed function should check for the response status using Fetch.Response.status. If the status is 200, then proceed as before, but if it's something else, it should call setData(_ => Loaded(Error("Error: Received status " ++ string_of_int(status)))).

The code should roughly look like this:

Fetch.fetch(...)
|> P.then_(response => {
      let status = Fetch.Response.status(response);
      if (status === 200) {
        /* If status is OK, proceed to parse the response */
        response
        |> Fetch.Response.text
        |> P.then_(...);
      } else {
        /* Handle non-200 status */
        setState(state =>
          {
            ...state,
            step:
              Loaded(
                Error(
                  "Error: Received status " ++ string_of_int(status),
                ),
              ),
          }
        )
        |> P.resolve;
      };
    })
|> ignore;

Tip

You can use either if / else or a switch expression for this.

The other change involves the rendering logic. We will have to modify the content of the ul element, so that instead of always iterating over the array of entries, we should use a switch (feed.entries) check. In case the array is empty, we can just render React.string("This user feed is empty").

With these two modifications, our app is a bit more user friendly.

Step 9 completion check

To test these changes, we can type a username like in to see the empty feed message. Testing with invalid usernames like invalid- will display an invalid username message, while nonexistent usernames like a---1 will show a server error message (this user doesn't exist, but this case is currently not handled by the API server).

Step 10: Adding styles

Melange allows us to build our apps using OCaml's powerful type system while leveraging existing JavaScript tooling.

In this step, we'll enhance our app's UI by adding some styles to the username input field using CSS modules. CSS modules are CSS files in which all class names are scoped locally by default.

First, let's add a new file in src called UsernameInput.module.css:

.container {
    margin-bottom: 20px;
    display: flex;
    align-items: center;
}

.container label {
    margin-right: 10px;
    font-weight: bold;
}

.container input {
    padding: 8px;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 16px;
    width: 250px;
}

.container input:focus {
    outline: none;
    border-color: #007bff;
    box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
}

Next, we'll add a new UsernameInput module. This step is not strictly necessary, but it will help us encapsulate the input logic and styles, making our app more modular and maintainable. Let's create src/UsernameInput.re and move the input rendering code into it:

[@react.component]
let make = (~username, ~onChange, ~onEnterKeyDown) =>
  <div>
    <label htmlFor="username-input"> {React.string("Username:")} </label>
    <input
      id="username-input"
      value=username
      placeholder="Enter GitHub username"
      onChange={event => onChange(event->React.Event.Form.target##value)}
      onKeyDown={event => {
        let enterKey = 13;
        if (React.Event.Keyboard.keyCode(event) == enterKey) {
          onEnterKeyDown();
        };
      }}
    />
  </div>;

Now, we can replace the input element in App with the new <UsernameInput ... /> component. At this point, you should be able to apply the necessary changes to make the build pass! :)

To import the module.css in our component, we'll define an external binding. In this case, we will annotate the external with the mel.module attribute, which allows to consume CSS or JS files from our OCaml files. After importing it, we can use it in the wrapping <div>:

[@mel.module "./UsernameInput.module.css"]
external container: string = "container";

[@react.component]
let make = (~username, ~onChange, ~onEnterKeyDown) =>
  <div className=container>
    <label htmlFor="username-input"> {React.string("Username:")} </label>
    <input .../>
  </div>;

Finally, let's update the src/dune file to include the CSS file as a runtime dependency in the app library. This step is necessary because Melange needs to know about any external resources that are used during runtime. By adding the CSS file as a runtime dependency, we ensure that Melange correctly handles and includes the styles in the build output. This output is placed in the _build/default directory, you generally won’t need to interact with this folder directly unless you’re troubleshooting or diving deeper into the build process.

(library
 (name app)
 ...
 (melange.runtime_deps img/camel-fun.jpg UsernameInput.module.css)
 ...)

Since Esbuild supports local CSS modules out of the box, just ensure that the style element is added to the index.html file:

  <div id="root"></div>
  <link href="App.css" rel="stylesheet">
  <script type="module" src="App.mjs"></script>

Step 10 completion check

Voilà! Since we updated index.html, you'll need to restart the dev server. Go ahead and stop the npm run serve process, then start it again. When you open the browser, you should see a more stylish input field.

Step 11: Automatically fetch more data on scroll

In this step, we'll implement "infinite scroll" to automatically fetch more data as the user scrolls to the bottom of the feed.

11.1: Add bindings to IntersectionObserver

We need to create bindings for IntersectionObserver to detect when the user has scrolled to the bottom of the feed.

In a new file IntersectionObserver.re, we will be using mel.send and mel.new:

// IntersectionObserver bindings
type entry = {
  isIntersecting: bool,
  target: Dom.element,
};

type observer;
[@mel.send] external observe: (observer, Dom.element) => unit = "observe";
[@mel.send] external disconnect: observer => unit = "disconnect";

[@mel.new] external make: (array(entry) => unit) => observer = "IntersectionObserver";

11.2: Update state for pagination

We’ll update our state to track the current page of the feed. We’ll also store the previous feed when in the Loading state.

type step =
  | Idle /* There is no request going on, and no data to be shown. In this case, we will just show instructions to proceed with the request. */
  | Loading(option(result(Feed.feed, string))) /* A request is currently taking place to fetch the feed. Stores the previously fetched feed. */
  | InvalidUsername /* The entered username is not a valid GitHub ID. */
  | Loaded(result(Feed.feed, string)); /* A request has finished, its result is contained inside the variant value itself. */

type state = {
  username: string,
  step,
  currentPage: int,
};

let (state, setState) =
  React.useState(() => {username: "jchavarri", step: Idle, currentPage: 1});

11.3: Add helper functions mergeFeeds and renderFeed

We are going to be needing these two functions, both can be placed outside the make function of App.re.

The first one takes the old feed and a new one, and proceeds to merge both:

let mergeFeeds = (oldFeed: Feed.feed, newFeed: Feed.feed): Feed.feed => {
  let combinedEntries = Array.concat([oldFeed.entries, newFeed.entries]);
  {entries: combinedEntries};
};

The second one is just taking the feed render code and moving it to a separate function so we can reuse it:

let renderFeed = (feed: Feed.feed) =>
  <div>
    <h1> {React.string("GitHub Feed")} </h1>
    <ul>
      {switch (feed.entries) {
       | [||] => React.string("This user feed is empty")
       | entries =>
         entries
         |> Array.map((entry: Feed.entry) =>
              <li key={entry.id}>
                <h2> {React.string(entry.title)} </h2>
                {switch (entry.content) {
                 | None => React.null
                 | Some(content) =>
                   <p dangerouslySetInnerHTML={"__html": content} />
                 }}
              </li>
            )
         |> React.array
       }}
    </ul>
  </div>;

11.4: Modify fetchFeed to support pagination

Update fetchFeed to accept a page parameter and include it in the API request. We’ll use labeled arguments to avoid confusion when calling this function:

let fetchFeed = (~username, ~page) => {
  setState(state =>
    {
      ...state,
      step:
        switch (state.step) {
        | Loaded(r)
        | Loading(Some(r)) => Loading(Some(r))
        | Loading(None)
        | Idle
        | InvalidUsername => Loading(None)
        },
    }
  );
  module P = Js.Promise;
  Fetch.fetch(
    "https://gh-feed.vercel.app/api?user="
    ++ Username.toString(username)
    ++ "&page="
    ++ string_of_int(page),
  )
  |> P.then_(response => {
        let status = Fetch.Response.status(response);
        if (status === 200) {
          /* If status is OK, proceed to parse the response */
          response
          |> Fetch.Response.text
          |> P.then_(text =>
              {
                let data =
                  try(Ok(text |> Json.parseOrRaise |> Feed.feed_of_json)) {
                  | Json.Decode.DecodeError(msg) =>
                    Js.Console.error(msg);
                    Error("Failed to decode: " ++ msg);
                  };

                switch (data) {
                | Error(_) =>
                  setState(state => {...state, step: Loaded(data)})
                | Ok(data) =>
                  setState(state => {
                    let updatedFeed =
                      switch (state.step) {
                      | Loaded(Ok(feed))
                      | Loading(Some(Ok(feed))) =>
                        mergeFeeds(feed, data)
                      | _ => data
                      };
                    {
                      ...state,
                      step: Loaded(Ok(updatedFeed)),
                      currentPage: page + 1,
                    };
                  })
                };
              }
              |> P.resolve
            );
        } else {
          /* Handle non-200 status */
          ...
        };
      })
  |> ignore;
};

11.5: Modify render code to include scroll sentinel and handling new state

We’ll add a sentinelRef to track the last element of the list, which will trigger loading more data when it comes into view.

In the render code, create a new React ref to track the last element of the list:

    let sentinelRef = React.useRef(Js.Nullable.null);

Tip

Js.Nullable.t is a type that represents a JavaScript value that could be either null or undefined. It differs from OCaml's Option.t, which represents a value that is either Some(value) or None.

Js.Nullable.t is primarily used for interacting with JavaScript APIs that return null or undefined. Option.t is more idiomatic to OCaml.

Also, add the element itself at the end of the switch:

  {switch (state.step) {
    | InvalidUsername => <div> {React.string("Invalid username")} </div>
    | Idle =>
      <div>
        {React.string(
          "Press the \"Enter\" key to confirm the username selection.",
        )}
      </div>
    | Loading(None | Some(Error(_))) =>
      <div> {React.string("Loading...")} </div>
    | Loading(Some(Ok(feed))) =>
      <> {renderFeed(feed)} <div> {React.string("Loading...")} </div> </>
    | Loaded(Error(msg)) => <div> {React.string(msg)} </div>
    | Loaded(Ok(feed)) => renderFeed(feed)
    }}
  <div ref={ReactDOM.Ref.domRef(sentinelRef)} />

11.6: Update the effect to fetch new data when observer is intersecting

Finally, let's update the effect to fetch more data when the IntersectionObserver detects that the sentinel element is in the viewport.

  React.useEffect1(
    () => {
      switch (Username.make(state.username)) {
      | Error () =>
        Js.Exn.raiseError("The value of state.username is invalid")
      | Ok(username) =>
        /* Use option to be able to use `switch`. Can't use pattern match when
        using abstract types like `Js.Nullable.t` */
        switch (Js.Nullable.toOption(sentinelRef.current)) {
        | None =>
          None;
        | Some(elem) =>
          let shouldFetch =
            switch (state.step) {
            | Loading(_) => false
            | Loaded(Ok({entries: [||]})) => false
            | Idle
            | InvalidUsername
            | Loaded(_) => true
            };
          let observer =
            IntersectionObserver.make(entries => {
              let entry = entries[0];
              if (entry.isIntersecting && shouldFetch) {
                fetchFeed(~username, ~page=state.currentPage);
              };
            });
          IntersectionObserver.observe(observer, elem);
          Some(() => IntersectionObserver.disconnect(observer));
        }
      }
    },
    [|state|],
  );

Step 11 completion check

Scroll to the bottom of the page in your browser. The app should automatically fetch more data and append it to the feed, creating an infinite scroll effect.

Project layout

The following is a high level view of the workshop project.

react-alicante-workshop
│
│   // This directory is generated by 'Dune', OCaml's build tool.
│   // It contains compiled files and other artifacts from the build process.
│   // Note: This is _not_ where your application bundle/output/dist will be.
├── _build/
│
│   // This directory is created by `Opam`, OCaml's package manager. It
│   // contains your "local switch" and packages for your OCaml
│   // environment, specific to this project. You can think of the `_opam`
│   // folder and switches as OCaml's `node_modules`.
├── _opam/
│
│   // This is where your applications bundle or output will be located after
│   // running `npm run build`.
├── dist/
│
├── node_modules/
│
├── src
│   │   // This is a ReasonReact Component and the entry point to your application
│   └── App.re
│
│   // This file tells ocamlformat ("Prettier for OCaml") that we want our sources to be formatted
├── .ocamlformat
│
│   // This `dune` file configures Dune's build rules.
├── dune
│
│   // This file contains the Dune's global settings.
├── dune-project
│
├── esbuild.mjs
├── index.html
├── package-lock.json
├── package.json
│
│   // It contains opam dependencies. It is similar to `package.json`. In the
|   // near future, `Dune` and `Opam` will be more tightly integrated.
├── react-alicante-workshop.opam
│
└── README.md

Installing Melange packages

To install dependencies, we are going to use opam. You can search for dependencies and packages on OCaml.org.

There are some requirements to use a package with Melange you should read about. That being said, once you find a package you want to install you can do the following steps:

  1. Add the library to the opam section
  2. Run the following command: npm run install:opam

Resources

About

Public repo for the "Write maintainable React applications using OCaml" React Alicante workshop

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published