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:
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.
Top comments (0)