DEV Community

loading...
Cover image for Build a type safe React App with ReasonML, Part 1
Techway

Build a type safe React App with ReasonML, Part 1

theodesp profile image Theofanis Despoudis ・7 min read

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

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

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

  • Create a babel.config.js file with the following contents:
module.exports = {
  env: {
    test: {
      plugins: ["transform-es2015-modules-commonjs"]
    }
  }
};
  • 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$"
    ]
  }
}

  • Finally install the npm dependencies:
$ npm i

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

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");

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

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

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

NotFound.re

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

Finally update the App component to render the Routes instead:

App.re

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

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

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

requireCSS('./styles.css');

and this will compile as:

require('./styles.css');

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

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

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

$ npm i style-loader css-loader file-loader --save-dev

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

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

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

The InventoryApi.re file contains the following content:

let fetch = callback => {
  callback(MockData.inventory);
};

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",
  },
...

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

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

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

Where CartApi.re is:

let fetch = callback => {
  callback(MockData.cart);
};

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

Next Part

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

Discussion

pic
Editor guide