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!
In order to start working on the project, you will need:
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.
ℹ️ 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.
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.
From the terminal:
git clone https://github.com/ahrefs/react-alicante-workshop
cd react-alicante-workshop
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.
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.
We will be using VSCode, which you can install from https://code.visualstudio.com/Download.
ℹ️ Windows users only: Install the WSL extension for VSCode.
Install the OCaml platform extension.
At the bottom of the VSCode window, you should see
opam(react-alicante-workshop)
:
If that's not the case, click on that 📦 button, and select
react-alicante-workshop
from the list that will appear:
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!
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.
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
andh2
fromApp.re
, and move them to a new fileHello.re
wrapping them with a fragment<>
and amake
function. Then use the new component fromApp
using JSX:<Hello />
.
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.
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
(astring
) andpage
(anint
). For example:https://gh-feed.vercel.app/api?user=jchavarri&page=1
- It returns an object (let's call it
feed
) with a keyentries
, 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
).
- href: The URL the link points to (
- title: The entry title (
string
). - updated: A timestamp of the last update (
float
).
- id: The id of the entry (
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
.
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.
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 thecatch
part. - To ensure consistent return types in the exception handling, we leverage optional types.
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:
Next, we’ll explore an easier way to decode data from JSON.
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;
};
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:
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.
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"
]
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)
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 aliasP
to the Melange API module Js.Promise.React.useEffect0
: Runs the fetch operation when the component mounts. TheuseEffect0
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
.
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
.
Now that we have successfully fetched the data, let’s move on to decoding it and displaying it in the UI.
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
andError
- 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;
});
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.
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
.
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 ausername
of typestring
and perform the network request to get data for that user. You'll need to dynamically insert theusername
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 callsfetchFeed(username)
:
React.useEffect0(() => {
fetchFeed(username);
None;
});
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.
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 astring
and returns aresult
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 convertsUsername.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 setstep
toLoading
at the beginning of the function (setState(state => {...state, step: Loading});
), andLoaded
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 callfetchFeed
, but if it's invalid, we will set the step toInvalidUsername
:
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}
.
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.
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).
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>
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.
In this step, we'll implement "infinite scroll" to automatically fetch more data as the user scrolls to the bottom of the feed.
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";
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});
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>;
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;
};
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)} />
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|],
);
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.
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
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:
- Add the library to the
opam
section - Run the following command:
npm run install:opam
- Melange for React Devs - This is an amazing resource for learning Melange, OCaml, and Reason even if you're not using React
- OCaml official site
- Reason official site
- Melange official site
- Melange playground - Useful to share snippets or errors
- Reason React docs
- OCaml Discuss Forums
- OCaml Discord Server
- Reason Discord Server