DEV Community

Cover image for Build a type safe React App with ReasonML, Part 1
Theofanis Despoudis for Techway

Posted on

Build a type safe React App with ReasonML, Part 1

Inspired by this tutorial.

I wanted to showcase a real world project using ReasonML which is an ecosystem of tools and libraries for developing type safe code using OCaml in the Browser. My aim is to help you see that there are not many differences between ReasonML and plain Javascript as the type system is smart enough to perform type inference without being too explicit.

In this example two-part series we’ll create a sample e-commerce app like the one shown in the inspired article above.

Let's get started:

Building a type-safe ReasonML app

We need to get started working with a ReasonML by configuring our project first.

First install the bsb-platform which is the ReasonML compiler tooling:

$ npm install -g bs-platform
Enter fullscreen mode Exit fullscreen mode

Next create a new ReasonML project using the React Hooks theme which will setup the necessary boilerplate project for us:

$ bsb -init reason-example -theme react-hooks
Enter fullscreen mode Exit fullscreen mode

The default boilerplate maybe not familiar for us. I recommend doing the following changes:

  • Remove the following files:
indexProduction.html
watcher.js
UNUSED_webpack.config.js
Enter fullscreen mode Exit fullscreen mode
  • Change the index.html like this:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>ReasonReact Examples</title>
</head>
<body>
  <div id="root"></div>
  <script src="Index.js"></script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode
  • Create a new webpack.config.js file with the following content:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const outputDir = path.join(__dirname, 'build/');

const isProd = process.env.NODE_ENV === 'production';

module.exports = {
  entry: './src/Index.bs.js',
  mode: isProd ? 'production' : 'development',
  output: {
    path: outputDir,
    filename: 'Index.js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'index.html',
      inject: false
    })
  ],
  devServer: {
    compress: true,
    contentBase: outputDir,
    port: process.env.PORT || 8000,
    historyApiFallback: true
  }
};
Enter fullscreen mode Exit fullscreen mode
  • Change the bsconfig.json file like this:
{
  "name": "reason-react-example",
  "reason": {
    "react-jsx": 3
  },
  "sources": [{
    "dir" : "src",
    "subdirs" : true
  }],
  "bsc-flags": ["-bs-super-errors", "-bs-no-version-header"],
  "package-specs": [{
    "module": "commonjs",
    "in-source": true
  }],
  "suffix": ".bs.js",
  "namespace": true,
  "bs-dependencies": [
    "reason-react"
  ],
  "bs-dev-dependencies": ["@glennsl/bs-jest"],
  "refmt": 3,
  "gentypeconfig": {
    "language": "typescript",
    "module": "es6",
    "importPath": "relative",
    "debug": {
      "all": false,
      "basic": false
    }
  }
}

Enter fullscreen mode Exit fullscreen mode
  • Create a babel.config.js file with the following contents:
module.exports = {
  env: {
    test: {
      plugins: ["transform-es2015-modules-commonjs"]
    }
  }
};
Enter fullscreen mode Exit fullscreen mode
  • Update the package.json so that it has the following contents:
{
  "name": "reason-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "bsb -make-world",
    "start": "bsb -make-world -w",
    "clean": "bsb -clean-world",
    "webpack": "webpack -w",
    "webpack:production": "NODE_ENV=production webpack",
    "server": "webpack-dev-server"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2",
    "bs-platform": "^7.2.2",
    "gentype": "^3.15.0",
    "webpack-cli": "^3.3.11"
  },
  "dependencies": {
    "@glennsl/bs-jest": "^0.5.0",
    "bs-fetch": "^0.5.2",
    "html-webpack-plugin": "^3.2.0",
    "react": "^16.13.0",
    "react-dom": "^16.13.0",
    "reason-react": "^0.7.0",
    "webpack": "^4.42.0",
    "webpack-dev-server": "^3.10.3"
  },
  "jest": {
    "transformIgnorePatterns": [
      "/node_modules/(?!@glennsl/bs-jest|bs-platform).+\\.js$"
    ]
  }
}

Enter fullscreen mode Exit fullscreen mode
  • Finally install the npm dependencies:
$ npm i
Enter fullscreen mode Exit fullscreen mode

If you want to test the application now you need to run the dev server and the bsb compiler in two tabs:

$ npm run start
// In another tab
$ npm run server
Enter fullscreen mode Exit fullscreen mode

However for the example you should delete all the examples inside the src folder and keep an Index.re file with the following example code:

ReactDOMRe.renderToElementWithId(<App />, "root");
Enter fullscreen mode Exit fullscreen mode

This is similar to React's ReactDOM.render method but a little bit more convenient.

Create a new file named App.re in the same folder and add the following code:

[@react.component]
let make = () => {
  <main> {"Hello From ReasonML" |> React.string} </main>;
};
Enter fullscreen mode Exit fullscreen mode

Let's explain here some conventions:

  • We use the [@react.component] annotation to specify that it's a react component
  • We name a let binding as make so that by default ReasonReact will discover it
  • We use regular JSX but when we want to display a string we need to pipe it to the appropriate type. In that case |> React.string.

Every-time you change anything in the code it will reload and see the changes to the UI.

Routing

ReasonReact comes with a router! Let's add the first route to match the home page:

Create a new file named Routes.re and add the following code:

[@react.component]
let make = () => {
  let url = ReasonReactRouter.useUrl();

  switch (url.path) {
  | [] => <Home />
  | _ => <NotFound />
  };
};
Enter fullscreen mode Exit fullscreen mode

This will match either the base path / rendering the Home component or anything else rendering the NotFound component.

Create the following components:

Home.re

[@react.component]
let make = () => {
  <main> {"Hello World  " |> React.string} </main>;
};
Enter fullscreen mode Exit fullscreen mode

NotFound.re

[@react.component]
let make = () => {
  <main> {"404 Page not found!" |> React.string} </main>;
};
Enter fullscreen mode Exit fullscreen mode

Finally update the App component to render the Routes instead:

App.re

[@react.component]
let make = () => {
  <Routes />;
};
Enter fullscreen mode Exit fullscreen mode

Now you know how to handle routing.

Styles and images

We can add stylesheets and images using regular require imports. We just need to define some external helpers that will map from ReasonML to Javascript.

Create a new file named Helpers.re and add the following code:

/* require css file */
[@bs.val] external requireCSS: string => unit = "require";

/* require an asset (eg. an image) and return exported string value (image URI) */
[@bs.val] external requireImage: string => string = "require";
Enter fullscreen mode Exit fullscreen mode

So whenever we want to include css files we use it like:

requireCSS('./styles.css');
Enter fullscreen mode Exit fullscreen mode

and this will compile as:

require('./styles.css');
Enter fullscreen mode Exit fullscreen mode

Let's add styles for the NotFound page:

NotFound.css

.NotFound {
    margin: 30px auto;
    display: flex;
    align-items: center;
    flex-direction: column;
}
.NotFound--image {
    margin-top: 60px;
}
Enter fullscreen mode Exit fullscreen mode

Change the NotFound.re component to import the styles:

open Helpers;

requireCSS("./NotFound.css");

let notFoundImage = requireImage("./notFound.png");

[@react.component]
let make = () => {
  <main className="NotFound">
    <div className="NotFound--Image">
      <img src=notFoundImage alt="Not Found Image" />
    </div>
  </main>;
};
Enter fullscreen mode Exit fullscreen mode

Finally you need to install the webpack dependencies and update the webpack.config:

$ npm i style-loader css-loader file-loader --save-dev
Enter fullscreen mode Exit fullscreen mode

webpack.config.js

...
module: {
    rules: [
      {
        test: /\.(png|jpe?g|gif)$/i,
        loader: 'file-loader',
        options: {
          esModule: false,
        },
      },
      {
        test: /\.css$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader',
            options: {
              sourceMap: true
            }
          }
        ]
      },
    ]
Enter fullscreen mode Exit fullscreen mode

You need to find a notFound.png image and place it inside the src folder. Once you run the application again you can see the not found page:

Modelling the Domain problem

We have two important domains in the wireframe, inventory and cart:

Diagram Showing Inventory and Cart Domains

We will create the application store and structure it based on the domain.

Let’s start with the inventory domain.

Inventory domain

ReasonReact has full support for React Hooks!. We can use reducers, effects, state, context variables to handle our application state. Let's start by defining our model types for the inventory domain based on the class diagram above.

Create a new file named InventoryData.re and add the following code:

type inventory = {
  id: string,
  name: string,
  price: int,
  image: string,
  description: string,
  brand: option(string),
  stockCount: int,
};

type action =
  | Fetch
  | FetchSuccess(list(inventory))
  | FetchError(string);

type state = {
  isLoading: bool,
  data: list(inventory),
  error: option(string),
};

let initialState = {isLoading: false, data: [], error: None};
Enter fullscreen mode Exit fullscreen mode

The above code contains state, action types, and inventory domain mode
A few notes about the code above:

The inventory type determines the specified domain data
The actions variant determines the action types
The state handles the type of domain state. We also define an initialState

Now, it’s time to create an action for fetching the inventory store. Create a new file named InventoryActions.re with the following contents:

let fetchInventory = dispatch => {
  dispatch(InventoryData.Fetch);
  InventoryApi.fetch(payload =>
    dispatch(InventoryData.FetchSuccess(payload))
  )
  |> ignore;
};
Enter fullscreen mode Exit fullscreen mode

The InventoryApi.re file contains the following content:

let fetch = callback => {
  callback(MockData.inventory);
};
Enter fullscreen mode Exit fullscreen mode

Finally the MockData.re file is just a hardcoded list of inventory items:

open InventoryData;

let inventory = [
  {
    name: "Timber Gray Sofa",
    price: 1000,
    image: "../images/products/couch1.png",
    description: "This is a Test Description",
    brand: Some("Jason Bourne"),
    stockCount: 4,
    id: "fb94f208-6d34-425f-a3f8-e5b87794aef1",
  },
  {
    name: "Carmel Brown Sofa",
    price: 1000,
    image: "../images/products/couch5.png",
    description: "This is a test description",
    brand: Some("Jason Bourne"),
    stockCount: 2,
    id: "4c95788a-1fa2-4f5c-ab97-7a98c1862584",
  },
...
Enter fullscreen mode Exit fullscreen mode

The final part of the inventory store is the reducer. Let's create that file:

InventoryReducer.re

open InventoryData;

let reducer: (state, action) => state =
  (state, action) =>
    switch (action) {
    | Fetch => {...state, isLoading: true}
    | FetchSuccess(data) => {...state, isLoading: false, data}
    | FetchError(error) => {...state, isLoading: false, error: Some(error)}
    };
Enter fullscreen mode Exit fullscreen mode

Here we included the InventoryData module so that the types are inferred without prefixing the module name. Note that we can ignore the type definition of the reducer without losing type checking. ReasonML is always on guard if something goes wrong with the types!.

Cart domain

It’s time to implement the types and actions for the cart model. The functionalities of the cart domain are similar to those of the inventory domain.

First, create a file named CartData.re and add the following code:

open InventoryData;

type cart = {
  id: string,
  items: list(inventory),
};

type action =
  | AddToCart(inventory)
  | RemoveFromCart(inventory)
  | Fetch
  | FetchSuccess(option(cart))
  | FetchError(string);

type state = {
  isLoading: bool,
  data: cart,
  error: option(string),
};

let initialState = {isLoading: false, data: {id: "1", items: []}, error: None};
Enter fullscreen mode Exit fullscreen mode

This represents the cart domain attributes, cart action types, and cart state.

Next, create CartActions.re for the cart domain:

let fetchCart = dispatch => {
  dispatch(CartData.Fetch);
  CartApi.fetch(payload => dispatch(CartData.FetchSuccess(payload)))
  |> ignore;
};

let addToCart = (inventory, dispatch) => {
  dispatch(CartData.AddToCart(inventory)) |> ignore;
};

Enter fullscreen mode Exit fullscreen mode

Where CartApi.re is:

let fetch = callback => {
  callback(MockData.cart);
};
Enter fullscreen mode Exit fullscreen mode

Finally, write the code for the cart domain reducer. Create a file, name it CartReducer.re, and add the following code:

open CartData;

let reducer: (CartData.state, CartData.action) => CartData.state =
  (state, action) =>
    switch (action) {
    | Fetch => {...state, isLoading: true}
    | FetchSuccess(data) => {...state, isLoading: false, data}
    | FetchError(error) => {...state, isLoading: false, error: Some(error)}
    | AddToCart(inventory) =>
      let updatedInventory = [inventory, ...state.data.items];
      {
        ...state,
        isLoading: true,
        data: {
          id: state.data.id,
          items: updatedInventory,
        },
      };
    | RemoveFromCart(inventory) =>
      let updatedInventory =
        List.filter(
          (item: InventoryData.inventory) => item.id != inventory.id,
          state.data.items,
        );
      {
        ...state,
        isLoading: true,
        data: {
          id: state.data.id,
          items: updatedInventory,
        },
      };
    };

Enter fullscreen mode Exit fullscreen mode

Next Part

We will continue in the next and final part of this tutorial by defining the view components and glue everything together.

Top comments (0)