DEV Community

luski
luski

Posted on

ReasonReact practice -  calendar from scratch

There are only a few examples of how to write web apps in Reason/rescript yet so I'd like to make my small contribution here. I'm going to show you how to implement a calendar component in ReasonReact from scratch, starting from setup the empty project and finishing on a simple calendar component that:

  • Displays the current month initially
  • Contains the navigation allowing to switch between months
  • Is configurable (allows to set the first day of the week)
  • Supports internationalization
  • Looks nice

The plan is to build the component using the following dependencies:

  • date-fns, to make all the calculations and display names of days and months in the selected language
  • tailwindcss, to make the component looking good

Let's start!

Setup the empty project

I'm new in the Reason ecosystem, but I have a lot of experience in JavaScript and React development, so to feel more confident, I'm going to start a new create-react-app application and just add Reason to it.

yarn create react-app reason-calendar
cd reason-calendar

More about how to create an empty app using create-react-app you can find here: https://create-react-app.dev/docs/getting-started/#yarn

Now let's remove all generated files, we don't need them:

cd src
rm App.css App.js App.test.js setupTests.js logo.svg index.css serviceWorker.js

Now open the index.js file and make it as simple as possible:

import React from 'react';
import ReactDOM from 'react-dom';

ReactDOM.render(
  <React.StrictMode>
    <div>Hello Reason</div>
  </React.StrictMode>,
  document.getElementById('root')
);

Run yarn start in the terminal to check if it's working.

Now it's time to set up ReasonReact. Again, nothing fancy here, I just suggest doing exactly the same what the ReasonReact docs ask you to do here.

cd .. # go to the root directory of your project
yarn add bs-platform --dev --exact
yarn add reason-react --exact

…and update the package.json:

"scripts": {
  "re:build": "bsb -make-world -clean-world",
  "re:watch": "bsb -make-world -clean-world -w"
}

…and create the bsconfig.json:

{
  "name": "reason-calendar",
  "reason": { "react-jsx": 3 },
  "bsc-flags": ["-bs-super-errors"],
  "sources": [
    {
      "dir": "src",
      "subdirs": true
    }
  ],
  "package-specs": [
    {
      "module": "es6",
      "in-source": true
    }
  ],
  "suffix": ".bs.js",
  "namespace": true,
  "bs-dependencies": [
    "reason-react"
  ],
  "ppx-flags": [],
  "refmt": 3
}

And that's actually it. You can check the repo here:

git clone git@github.com:luski/reason-calendar-tutorial.git --branch step-1

What we will make

I believe it's high time to define what exactly we are going to implement. The following image shows a calendar from one of the mockups that I was supposed to implement the other day. Let's use it and try to implement it:

calendar

As you can see, the component presents a month view. It's in polish, but no worries, we are going to support internationalization. As you can see, in the header we have arrows for switching between months and the MONTH/YEAR header.

Below, there is a list of days of a week and in the middle - day numbers. The current day has an accent color and days not belonging to the currently rendered month are grayed out.

The plan

Let's start by splitting the task into subtasks. Here's my proposal:

  1. Make a UI with dummy data
  2. Implement the logic
  3. Style the UI with tailwindcss

UI with dummy data

Let's start with the dummy data. It would help us with defining the data structure. The simplest what comes to my mind is to define two functions: one returning a list of day names (for displaying the header row) and another returning a list of lists of days with metadata (for displaying weeks). Here's the code:

/* MockData.re */
type dayOfMonth = {
  number: int,
  isToday: bool,
  isForeign: bool,
};

let daysOfMonth = [|
  [|
    {number: 31, isToday: false, isForeign: true},
    {number: 1, isToday: false, isForeign: false},
    {number: 2, isToday: false, isForeign: false},
    {number: 3, isToday: false, isForeign: false},
    {number: 4, isToday: false, isForeign: false},
    {number: 5, isToday: false, isForeign: false},
    {number: 6, isToday: false, isForeign: false},
  |],
  [|
    {number: 7, isToday: false, isForeign: false},
    {number: 8, isToday: false, isForeign: false},
    {number: 9, isToday: false, isForeign: false},
    {number: 10, isToday: false, isForeign: false},
    {number: 11, isToday: true, isForeign: false},
    {number: 12, isToday: false, isForeign: false},
    {number: 13, isToday: false, isForeign: false},
  |],
  [|
    {number: 14, isToday: false, isForeign: false},
    {number: 15, isToday: false, isForeign: false},
    {number: 16, isToday: false, isForeign: false},
    {number: 17, isToday: false, isForeign: false},
    {number: 18, isToday: false, isForeign: false},
    {number: 19, isToday: false, isForeign: false},
    {number: 20, isToday: false, isForeign: false},
  |],
  [|
    {number: 21, isToday: false, isForeign: false},
    {number: 22, isToday: false, isForeign: false},
    {number: 23, isToday: false, isForeign: false},
    {number: 24, isToday: false, isForeign: false},
    {number: 25, isToday: false, isForeign: false},
    {number: 26, isToday: false, isForeign: false},
    {number: 27, isToday: false, isForeign: false},
  |],
  [|
    {number: 28, isToday: false, isForeign: false},
    {number: 29, isToday: false, isForeign: false},
    {number: 30, isToday: false, isForeign: false},
    {number: 31, isToday: false, isForeign: false},
    {number: 1, isToday: false, isForeign: true},
    {number: 2, isToday: false, isForeign: true},
    {number: 3, isToday: false, isForeign: true},
  |],
|];

let daysOfWeek = [|"sun", "mon", "tue", "wed", "thu", "fri", "sat"|];
/* CalendarView.re */
let daysOfWeek = () => MockData.daysOfWeek;
let daysOfMonth = () => MockData.daysOfMonth;

[@react.component]
let make = () => {
  <div>
    // Here goes the calendar UI
  </div>;
};

Such a data structure allows us to build the UI very straightforward. The calendar is a table with a header and rows for each week. Besides, above the table we have a row with arrows and MONTH/YEAR title. Here's how I implemented it. It's rather self-explanatory, looks almost like a pure React.

/* CalendarView.re */
[@react.component]
let make = () => {
  <div>
    <button> {React.string("<")} </button>
    <span> {React.string("September / 2020")} </span>
    <button> {React.string(">")} </button>
    <table>
      <thead>
        <tr>
          {daysOfWeek()
           ->Belt.Array.map((day: string) =>
               <th key=day> {React.string(day)} </th>
             )
           ->React.array}
        </tr>
      </thead>
      <tbody>
        {daysOfMonth()
         ->Belt.Array.mapWithIndex((weekNumber, days) =>
             <tr key={string_of_int(weekNumber)}>
               {days
                ->Belt.Array.map(({number, isToday, isForeign}) =>
                    <td key={string_of_int(number)}>
                      {number->string_of_int->React.string}
                    </td>
                  )
                ->React.array}
             </tr>
           )
         ->React.array}
      </tbody>
    </table>
  </div>;
};

Now we can add our sketch to the main app to see how it looks. Open the index.js and import the component:

import React from "react";
import ReactDOM from "react-dom";
import { make as Calendar } from "./CalendarView.bs";

ReactDOM.render(
  <React.StrictMode>
    <div>
      <Calendar />
    </div>
  </React.StrictMode>,
  document.getElementById("root")
);

As the listing shows, importing the ReasonReact components to JS code is almost as simple as importing React components. We must only remember that they are visible as make field.

Here's the result:

calendar-sketch

Repo for this step:

git clone git@github.com:luski/reason-calendar-tutorial.git --branch step-2

Logic implementation

Let's make it work. We are going to use the date-fns, so first, we need to add it to the project:

yarn add date-fns

Also, we need to bind this library. There are at least two binding libraries, but none of them fully worked for me, so let's just bind the functions that we need. I'd suggest starting with the JS version of the code and later transform it into Reason. Actually this is how the docs recommend doing.

import {format, addDays, startOfWeek} from 'date-fns';

const daysOfWeek = () => {
  const days = [0, 1, 2, 3, 4, 5, 6];
  const firstDayOfWeek = startOfWeek(new Date());

  return days.map(
    dayNumber => format(addDays(firstDayOfWeek, dayNumber), 'EE')
  );
}

The above function returns a list of day names starting from the first day of a week, it would be (for english): ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].

So first we need to bind the format, addDays, and startOfWeek from date-fns:

module DateFns = {
  type date = Js.Date.t;

  [@bs.module "date-fns"] external format: (date, string) => string = "format";
  [@bs.module "date-fns"] external addDays: (date, int) => date = "addDays";
  [@bs.module "date-fns"] external startOfWeek: date => date = "startOfWeek";
};

And implementation, pretty similar to the JS version, isn't it?

let daysOfWeek = () => {
  open DateFns;
  open Belt.Array;

  let days = [|0, 1, 2, 3, 4, 5, 6|];
  let firstDayOfWeek = startOfWeek(Js.Date.make());

  days->map(dayNumber =>
    firstDayOfWeek->addDays(dayNumber)->format(_, "EE")
  );
};

And finally, we can solve the main challenge of the task - implement a function generating weeks of the selected month. Again, let's start with the JS implementation:

import {startOfWeek, startOfMonth, addDays, isSameDay} from 'date-fns';

function daysOfMonth(date) {
  // first day that appears in the month view.
  // It's a first day of the first week of the month.
  const firstDayOfWeek = startOfWeek(startOfMonth(date));

  return generateWeeks(date.getMonth(), firstDayOfWeek, []);
}

function generateWeeks(monthNum, firstDayOfWeek, weeksAccumulator) {
  const week = [0, 1, 2, 3, 4, 5, 6]  // days of week
    .map(dayNumber => addDays(firstDayOfWeek, dayNumber)) // days of week transformed into dates
    .map(date => createDateOfWeek(monthNumber, date)); // dates transformed into dayOfWeek objects

  if (week[6].isForeign) {
     // last generated day is not belonging to the current month so we stop the recursion
    return [...weeksAccumulator, week];
  } else {
    // otherwise we generate next week
    return generateWeeks(monthNum, addDays(firstDayOfWeek, 7), [...weeksAccumulator, week]);
  }
}

function createDateOfWeek(monthNumber, date) {
  return {
    number: date.getDate(),
    isToday: isSameDay(date, new Date()),
    isForeign: date.getMonth() === monthNumber
  };
}

The code can look a bit unusual for a JS programmer because instead of a simple loop I used recurrency. I think it's a more natural way of executing loop in functional programming because we don't need any temporal mutable variables.

The first function, daysOfMonth takes some date and generates an array of month weeks represented as an array of objects defined in MockData.re  -  dayOfMonth. I hope the code is self-explanatory and doesn't need any additional comments. So let's change it to Reason.

First, we need to update our bindings:

module DateFns = {
  type date = Js.Date.t;

  [@bs.module "date-fns"] external format: (date, string) => string = "format";
  [@bs.module "date-fns"] external addDays: (date, int) => date = "addDays";
  [@bs.module "date-fns"]
  external addMonths: (date, int) => date = "addMonths";
  [@bs.module "date-fns"] external startOfWeek: date => date = "startOfWeek";
  [@bs.module "date-fns"] external startOfMonth: date => date = "startOfMonth";
  [@bs.module "date-fns"] external isSameDay: (date, date) => bool = "isSameDay";
};

Next, we just rewrite the code:

type dayOfMonth = {
  number: int,
  isToday: bool,
  isForeign: bool,
};

let daysOfWeek = () => {
  open DateFns;
  open Belt.Array;

  let days = [|0, 1, 2, 3, 4, 5, 6|];
  let firstDayOfWeek = startOfWeek(Js.Date.make());

  days->map(dayNumber =>
    firstDayOfWeek->addDays(dayNumber)->format("EE")
  );
};

let createDateOfWeek = (monthNumber, date) => {
  number: Js.Date.getDate(date)->int_of_float,
  isToday: DateFns.isSameDay(date, Js.Date.make()),
  isForeign: Js.Date.getMonth(date) !== monthNumber,
};

let rec generateWeeks = (monthNum, startingDay, weeks) => {
  open Belt.Array;
  open DateFns;

  let week =
    [|0, 1, 2, 3, 4, 5, 6|]
    ->map(addDays(startingDay))
    ->map(createDateOfWeek(monthNum));

  if (!getUnsafe(week, 6).isForeign) {
    generateWeeks(
      monthNum,
      startingDay->addDays(7),
      concat(weeks, [|week|]),
    );
  } else {
    concat(weeks, [|week|]);
  };
};

let daysOfMonth = date => {
  open DateFns;

  let firstDayOfWeek = date->startOfMonth->startOfWeek;
  generateWeeks(Js.Date.getMonth(date), firstDayOfWeek, [||]);
};

Notice I moved the type declaration from MockData.re to CalendarView.re because we just implemented all functions from the MockData.re and now we can delete this file 🎉

Notice also that the generateWeeks function has the rec keyword. Recursive functions must be marked with this keyword.

And here are the fruits of our work:
preview

The full code of this step:

git clone git@github.com:luski/reason-calendar-tutorial.git --branch step-3

Internationalization support

By internationalization of the Calendar, I mean adding support for other languages than english and configuring the starting day of the week because in many countries Monday is the starting day.

Luckily date-fns provides all these features, we only need to use them.

The format function from date-fns takes as optional argument the configuration object. This object can contain field named locale and it is used to display results of the function is the desired language. Here's how to do it in JavaScript:

import pl from 'date-fns/locale/pl';
import {format} from 'date-fns';

///...

const polishText = format(new Date(), 'EE', {locale: pl});

If we want to mark a different day than Sunday as the first day of a week, we need to pass an extra configuration object to function startOfWeek. Again, here's how to do it in JavaScript:

import {startOfWeek, startOfMonth} from 'date-fns';

const lastMonday = startOfWeek(new Date(), {weekStartsOn: 1});

Let's change our code!

First, the bindings. We need to use a locale object of unknown type (it's not really unknown, but we don't care what's inside). We can get instances of this type by importing them from date-fns/locale package. Let's then introduce a new module inside DateFns and name it Locale. For convenience let's add two instances of Locale inside, for instance en-US and pl:

module DateFns = {
  // ...
  module Locale = {
    type t;
    [@bs.module "date-fns/locale/pl"] external pl: t = "default";
    [@bs.module "date-fns/locale/en-US"] external en: t = "default";
  };

  type formatOptions = {locale: Locale.t};

  [@bs.module "date-fns"]
  external format: (date, string, formatOptions) => string = "format";
  // ...
};

What is worth to mention, I defined type t inside the Locale module. The name is taken by the convention. Usually, modules representing a type have this type defined inside and its name is t. As you noticed, this type is only a declaration, we don't know what is inside! It's fine because this type reprensents an object imported from date-fns, we don't care what's inside since we use it only to pass it to external functions. Reason doesn't care as well what's inside so we won't get any compilation errors there.

But we will see a compilation error anyway, because we added the second argument to the format function and we didn't change calls of that function yet, so let's do it now!

I'd like to allow external users of the Calendar to specify the location and if they don't do it, I'd use the 'us-US' as default. So first, we need to add an optional property to the component:

[@react.component]
let make = (~locale=?) => {
  let localeValue = Belt.Option.getWithDefault(locale, DateFns.Locale.en);
 //... rest of implementation

And now, having the localeValue inside the component we only need to pass it to all occurrences of the DateFns.format, for example here's the changed daysOfWeek:

let daysOfWeek = (locale) => {
  open DateFns;
  open Belt.Array;

  let days = [|0, 1, 2, 3, 4, 5, 6|];
  let firstDayOfWeek =
    startOfWeek(Js.Date.make());

  days->map(dayNumber =>
    firstDayOfWeek->addDays(dayNumber)->format("EE", {locale: locale})
  );
};

The same process must be done with the starting day of week. First, bindings:

module DateFns = {
  // ...
  type weekStartsOnOptions = {weekStartsOn: int};

  external startOfWeek: (date, weekStartsOnOptions) => date = "startOfWeek";
  // ...
};

Next, let's introduce another optional property to the component:

[@react.component]
let make = (~locale=?, ~weekStartsOn=?) => {
  // ...
  let weekStartsOnValue = Belt.Option.getWithDefault(weekStartsOn, 0);

// ...

Finally, let's pass weekStartOnValue to functions using startOfWeek.

Voilà, the component is ready. To test it, we must pass the custom configuration in index.js:

import React from "react";
import ReactDOM from "react-dom";
import { pl } from "date-fns/locale";

import { make as Calendar } from "./CalendarView.bs";

ReactDOM.render(
  <React.StrictMode>
    <div>
      <Calendar locale={pl} weekStartsOn={1} />
    </div>
  </React.StrictMode>,
  document.getElementById("root")
);

Here's how it works:

Internationalization

You can check the code here:

git clone git@github.com:luski/reason-calendar-tutorial.git --branch step-4

Styling with tailwindcss

Add tailwind to your project

Regardless of whether we write the app in React or ReasonReact, the final output is a plain javascript code. When we realize that, it becomes clear that using any external tools working with React will work also with ReasonReact. Therefore, to integrate tailwindcss with our project we just need to follow the guide on tailwincss.

Let's then do what the getting started page suggests doing:

  1. install the dependencies

    yarn add --dev tailwindcss postcss-cli
    
  2. create the base css file, name it (for instance) tailwind.css and store in /src/styles folder:
    tailwind.css:

    @tailwind base;
    @tailwind components;
    @tailwind utilities;
    
  3. create the configuration file tailwind.config.js

    npx tailwindcss init
    
  4. create scripts for building styles in package.json

    "scripts": {
      "build:css": "postcss src/styles/tailwind.css -o src/styles/style.css",
      "watch:css": "postcss src/styles/tailwind.css -o src/styles/style.css -w"
    }
    
  5. create postcss.config.js file:

    module.exports = {
     plugins: [require("tailwindcss"), require("autoprefixer")],
    };
    

Now if you run yarn run build:css the app.css file will appear in /src/styles. It will be build from tailwind.css using configuration from tailwind.config.js. If you run yarn run watch:css the app.css will be rebuilt every time when you change something in the app.css.

So let's call yarn run build:css to create the styles/style.css and include this file in the main file of the app: index.js:

import "./styles/style.css";

Configure

To make the calendar look like on the graphics, we need to add the right color palette and the right font to the tailwind. Here's the updated tailwind.config.js:

module.exports = {
  theme: {
    fontFamily: {
      lato: "Lato, sans-serif",
    },
    colors: {
      black: "#000000",
      white: "#FFFFFF",
      yellow: "#f2cb80",
      gray: {
        100: "#191c1e",
        900: "#808182"
      },
    }
  },
}

There is a lot of ways of adding the font to the project. I decided to add the link tag to the header of the index.html file:

    <link
      href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700;900&display=swap"
      rel="stylesheet"
    />

Now we are all good and can finally do the coding 😁

Using tailwind

Using tailwind is not rocket science, we're just adding classes to elements. Instead of printing my all changes, that you can find in the repo, I'd like to give you several tips that I used in this project.

VSCode plugin for tailwind

If you're using VSCode, I strongly suggest installing the tailwind intellisense plugin. Look how nice it works:

plugin

re-classnames library

I deliberately ignored the isForeign and isToday flags that can be found in our data model. It's because those flags are used only to change the decoration of the calendar days and now it's high time for handling it.

According to the graphics design, the regular days should be white, foreign days – gray, and today – yellow. In other words, the regular days get the class name text-white, foreign: text-gray-900, today: text-yellow.

For setting classnames dependent on some state I like using the re-classnames utility - similarly like I use classnames utility in JavaScript.

So let's add the library:

yarn add re-classnames

We must also add it to the bsconfig.json to make it visible by Reason:

"bs-dependencies": [
  "reason-react",
  "re-classnames"
],

And here's how to implement the coloring of days with this library:

<td
  key={string_of_int(number)}
  className=Cn.(
      "text-white"->on(!isToday && !isForeign)
    + "text-yellow"->on(isToday)
    + "text-gray-900"->on(isForeign)
  )>
  {number->string_of_int->React.string}
</td>

You'll probably notice the warning 44. The re-classnames docs suggest to turn it off, so let's do it by adding the following text to bsconfig.json:

"warnings": {
  "number": "-44"
}

Well, that's all that I wanted to share regarding the tailwindcss. Here's the repo of the complete solution of our task:

git clone git@github.com:luski/reason-calendar-tutorial.git --branch step-5

And look at the demo. Looks good, doesn't it?

Demo

Summary

I've written this post because I'm learning rescript and the calendar component was a widget that I really had to implement. I learned a couple of new things during the implementation so I decided to share my knowledge hoping that it can be helpful for other newcomers.

I just realized that the implementation of this small widget covers a lot of rescript and ReasonReact crucial elements. We learned how to:

  1. Write functional React components with internal state and optional parameters
  2. Import ReasonReact components to JS code
  3. Interop with JS libraries - by using date-fns
  4. Write recursive functions
  5. Set up a new project from scratch
  6. And maybe other things that I don't remember now? 😉

It was my first blog post, so please let me know what you think about it and share it if you think it's somehow valuable.

Top comments (1)

Collapse
 
jonocodes profile image
Jonathan Stiansen

Thanks for putting this together, really appreciated and useful.