DEV Community

Cover image for ReasonML with React Hooks Tutorial — Building a Pomodoro Timer
Ian Wilson
Ian Wilson

Posted on

ReasonML with React Hooks Tutorial — Building a Pomodoro Timer

Source code available on github

ReasonML is a programming language that combines the simplicity of JavaScript with the performance of OCaml. JavaScript programmers should give ReasonML a try. Reason inherits OCaml's typesystem as well as the approachability of JavaScript.

With ReasonReact and BuckleScript, we can compile Reason code to JavaScript that works like good old React.

But that's not all, things are getting even better for the Reason ecosystem. Earlier last week, ReasonReact version 0.7.0 dropped, with support for React hooks. We can now build robust components with React hooks that:

  • eliminate duplicated lifecycle logic (like in componentDidMount, componentDidUpdate, and componentWillUnmount)
  • eliminate the need for complicated patterns like higher-order components or render props
  • allow easier refactoring of stateful components

Think for a moment about that last point, what else give us easier refactorability?

A rock solid typesystem and a helpful compiler!

Today, we're going to build a Pomodoro Timer like this using Reason and React.

A pomodoro timer allows you to set a timer for say 25 or 30 minutes that lets you know when to stop working. Because sometimes taking a break is good, and you better believe I'm using one while writing this.

Initializing a Project

The first step is to install BuckleScript, a tool that will compile our Reason code to JavaScript.

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

After that, we can scaffold our project with the init command, and setting the theme to react-hooks.

bsb -init pomodoro-timer -theme react-hooks
cd pomodoro-timer && npm install
Enter fullscreen mode Exit fullscreen mode

Now that our project set up, we can mention our folder layout. Our Reason code belongs in the src directory. Where we usually have an index.js file in a JavaScript React project, we have an Index.re file. These files serve the same purpose: render the app root.

Since our code will compile to JavaScript, I still like to have a bundler like Webpack to bundle those files.

Tip: It is generally considered a best practice to keep your folder structure as flat as possible with ReasonML, using captialized file names (like Timer.re). See the Reason [docs](https://reasonml.github.io/docs/en/project-structure#folders) for a discussion on project structure.

Running the code

Before I clean up the environment, let us try running the code. This will need two or three separate terminal tabs:

# in one tab
npm start
Enter fullscreen mode Exit fullscreen mode

This runs bsb -make-world -w which compiles your Reason files on every change. It does this quickly, mind you.

# in another tab
npm run webpack
Enter fullscreen mode Exit fullscreen mode

This command takes our compiled JavaScript and spits out a bundle that we can then open in a browser using:

open build/index.html
Enter fullscreen mode Exit fullscreen mode

I prefer use a static file server like zeit/serve.

# install `serve` globally
npm i -g serve

# serve it!
serve build/
Enter fullscreen mode Exit fullscreen mode

initial project

Cleaning up the Environment

I want to clean up the default project structure before writing an implementation.

  1. Remove the Component1.re and Component2.re files, we will not be using these. You can still read them and try to making sense of how hooks are working in our new environment.
  2. Replace index.html with the following:
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Pomodoro Timer</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="Index.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode
  1. Replace Index.re with the following:
ReactDOMRe.renderToElementWithId(<Timer />, "root");
Enter fullscreen mode Exit fullscreen mode
  1. Open up bsconfig.json and change the field "in-source" from true to false. This will output our compiled JavaScript into the lib/js directory. I prefer this over leaving them inside of our root src directory.

  2. Tell webpack to build from the new location by changing webpack.config.js to the following:

module.exports = {
  entry: './lib/js/src/Index.bs.js',
  // rest of webpack config
}
Enter fullscreen mode Exit fullscreen mode

And now with all out that taken care of, we can begin the fun part, the implementation.

Building a Stateless Component

After cleaning up, notice that we are trying to render a Timer component but we have not even defined one. Let us write a stateless component to render to the page.

[@react.component]
let make = () => {
  <div> {ReasonReact.string("One day this will be a timer")} </div>;
};
Enter fullscreen mode Exit fullscreen mode

I want to highlight how simple this code is. Writing a stateless React component in the previous version was more verbose:

/* DO NOT WRITE THIS */
let component = ReasonReact.statelessComponent("Timer");
let make = () => {
  ...component,
  render: _children =>  <div> {ReasonReact.string("One day this will be a timer")} </div>;
};
Enter fullscreen mode Exit fullscreen mode

Even compared to regular JavaScript with React, this new syntax is incredibly slick. With Reason projects, we do not need to layer imports on top of the file every time we make a new file.

The useReducer Hook

Now we are going to introduce some state into this equation. We do not have any "class" components, we are still using functional components. But now we are going to use React hooks to compose a solution.

For our timer, we need to keep track of at least two things:

  1. The seconds on the clock
  2. Whether it is ticking or not

When we consider any timer, we usually have three buttons: Start, Stop, and Reset. These map to directly to user interactions, but there is one more: the Tick itself. Even though the tick is not controlled by the user, it is still an action that should return a new state for our reducer.

At the top of Timer.re, where we will build this whole app, add the following:

type state = {
  seconds: int,
  isTicking: bool
};

type action =
  | Start
  | Stop
  | Reset
  | Tick;
Enter fullscreen mode Exit fullscreen mode

How easy it is, to implement an application after you have described your states needs and actions!

Next we'll update our make function to use a reducer:

[@react.component]
let make = () => {
  let (state, dispatch) =
    React.useReducer(
      (state, action) =>
        switch (action) {
        | Start => {...state, isTicking: true}
        | Stop => {...state, isTicking: false}
        | Reset => {...state, seconds: 30}
        | Tick => {...state, seconds: state.seconds - 1}
        },
      {isTicking: false, seconds: 30},
    );

  <div>
    {ReasonReact.string(
       "There are " ++ string_of_int(state.seconds) ++ " on the clock",
     )}
  </div>;
};
Enter fullscreen mode Exit fullscreen mode

The syntax is like that of ReactJS. The useReducer function returns a tuple as opposed an array. This is because JavaScript does not know what the heck a tuple is. We make sure to handle each of our action types inside this reducer, returning a whole copy of the state.

The second argument to useReducer is the initial state of the reducer. It would make sense for the time to receive the initial time as a prop but I'll leave it this way for now.

Now we need a way to invoke these actions. Let us start with the Tick because it is the most complicated bit.

The useEffect hook

To update the timer every second, we need to create an effect. In the JavaScript world, this means putting a callback function within setInterval.

Let us add this hook between the useReducer function and the returned JSX.

React.useEffect0(() => {
  let timerId = Js.Global.setInterval(() => dispatch(Tick), 1000);
  Some(() => Js.Global.clearInterval(timerId));
});
Enter fullscreen mode Exit fullscreen mode

If you recall the way useEffect works:

  • The first argument is a callback function that runs when the component mounts, updates, or unmounts.
  • Optionally, the callback function can return another function that performs cleanup. This includes unsubscribing from sockets or clearing a setInterval so it does not persist forever.

If you run the code and refresh your browser, you'll see that our timer is going to run indefinitely.

It should not do this, so lets update our reducer again to account for whether the isTicking flag is true or false. We will also make sure that seconds are greater than zero so we do not go negative.

let (state, dispatch) =
    React.useReducer(
      (state, action) =>
        switch (action) {
        | Start => {...state, isTicking: true}
        | Stop => {...state, isTicking: false}
        | Reset => {...state, seconds: 30}
        | Tick => state.isTicking && state.seconds > 0
            ? {...state, seconds: state.seconds - 1} : state
        },
      {isTicking: false, seconds: 30},
    );
Enter fullscreen mode Exit fullscreen mode

Now you can verify this by setting isTicking to true and seconds to 3. Lovely, this app is functional now. If you wanted to actually use it, refresh your browser and watch it count down.

Interactivity with Buttons

To make this Timer even better, I am going to add buttons.

Let us define a Button module in the same file as our Timer, right above the make function.

module Button = {
  [@react.component]
  let make = (~label, ~onClick) => {
    <button onClick> {label |> ReasonReact.string} </button>;
  };
};
Enter fullscreen mode Exit fullscreen mode

This button takes a label and an onClick prop. We can add them to our Timer, at the bottom of the make function like so:

[@react.component]
let make = () => {
  /* useReducer */

  /* useEffect0 */

  <div>
      {ReasonReact.string(
        "There are " ++ string_of_int(state.seconds) ++ " seconds on the clock",
      )}
      {state.isTicking
        ? <Button label="STOP" onClick={_event => dispatch(Stop)} />
        : <>
            <Button label="START" onClick={_event => dispatch(Start)} />
            <Button label="RESET" onClick={_event => dispatch(Reset)} />
          </>}
    </div>;
};
Enter fullscreen mode Exit fullscreen mode

Try running this in your browser, you should see:

start and stop buttons

If you click START, it will start. If you click STOP it will stop. RESET will bring the timer back up to the initial time. Pretty straightforward how it just works!

At this point, our app is feature-complete. Read on to make it even cooler, with style and other aesthetic details.

Aesthetic Concerns

Let us add some styles and make this timer look pretty. Or at least better.

Update the JSX in Timer to the following:

[@react.component]
let make = () => {
  /* useReducer */

  /* useEffect0 */
<div
    style={ReactDOMRe.Style.make(
      ~border="1px solid black",
      ~borderRadius="8px",
      ~maxWidth="180px",
      ~textAlign="center",
      (),
    )}>
    <p
      style={ReactDOMRe.Style.make(
        ~color="#444444",
        ~fontSize="42px",
        ~margin="16px 0",
        (),
      )}>
      {state.seconds |> formatTime |> ReasonReact.string}
    </p>
    {state.isTicking
       ? <Button label="STOP" onClick={_event => dispatch(Stop)} />
       : <>
           <Button label="START" onClick={_event => dispatch(Start)} />
           <Button label="RESET" onClick={_event => dispatch(Reset)} />
         </>}
  </div>;
};
Enter fullscreen mode Exit fullscreen mode

I have introduced the default styles that come with ReasonReact. They are not my preferred style solution as I like bs-css better, but for this small project, they will do fine.

I also introduce a formatTime function. Let us implement that function so our project compiles:

let padNumber = numString =>
  if (numString |> int_of_string < 10) {
    "0" ++ numString;
  } else {
    numString;
  };

let formatTime = seconds => {
  let mins = seconds / 60;
  let minsString = mins |> string_of_int |> padNumber;
  let seconds = seconds mod 60;
  let secondsString = seconds |> string_of_int |> padNumber;
  minsString ++ ":" ++ secondsString;
};
Enter fullscreen mode Exit fullscreen mode

I included padNumber as well, a useful utility that pads minutes and seconds which are less than 10. This makes the timer more reader friendly.

I will be honest, this timer looks pretty good right now and I have not even styled the buttons. I will leave that as a task for the reader if they are so inclined.

finished timer

Update the Document Title

The concept of a timer in the browser can be further improved. If we have the time remaining persist in the browser tab itself, it would be visible if the user is in another tab.

All we have to do to get that going is update our Tick action in the reducer. I'll even show off ReasonML's interop with JavaScript by updating the document title. Note the %bs.raw directive, this allows us to put on the JavaScript training wheels and use it directly.

let updateTitle: string => unit = [%bs.raw
  {|
  function updateTitle(remaining) {
    document.title = "⏰ " + remaining + " ⏰";
  }|}
];

[@react.component]
let make = () => {
let (state, dispatch) =
    React.useReducer(
      (state, action) =>
        switch (action) {
        | Start => {...state, isTicking: true}
        | Stop => {...state, isTicking: false}
        | Reset => {...state, seconds: 30}
        | Tick =>
          state.isTicking && state.seconds > 0
            ? {
              updateTitle(formatTime(state.seconds - 1));
              {...state, seconds: state.seconds - 1};
            }
            : state
        },
      {isTicking: false, seconds: 30},
    );

/* useEffect0 */


/* JSX return */
};
Enter fullscreen mode Exit fullscreen mode

And with that, I am finished with the implementation of the timer:

finished timer with title update

Summary

With this tutorial we tackled the updated ReasonReact syntax with React hooks. when React hooks dropped late last year I could not wait to be able to use them in Reason projects. Now that they are here, we can take advantage of strong typed, composable, functional code.

We learned:

  • how to make a basic stateless component
  • how to use the useReducer hook
  • how to use the useEffect hook
  • how to define other React components in the same file/module.
  • how to use basic styles in ReasonReact
  • how to do interop with %bs.raw

I hope this has been a fun or useful experience, be sure to follow me @iwilsonq on dev.to, Twitter, Medium or anywhere else I roam.

If you want to how ReasonML works seamlessly with GraphQL, check out this article I wrote on ReasonML with GraphQL:

If you'd like to keep up with future posts, sign up for my newsletter here!

Top comments (16)

Collapse
 
diogomqbm_ profile image
Diogo Mafra • Edited

Nice post! I tried to implement following your instructions but I'm getting an error on

   { state.seconds |> formatTime |> str  }
   Unbound value str

where is str declared?

P.S: I'm new to ReasonML

Collapse
 
iwilsonq profile image
Ian Wilson • Edited

Oh woops, change str to ReasonReact.string,

or put this at the top of your file:

let str = ReasonReact.string;

Sorry about that! And welcome to ReasonML I hope you enjoy playing with it :)

Collapse
 
diogomqbm_ profile image
Diogo Mafra

Thank you!

Collapse
 
idkjs profile image
Alain • Edited

What version of bs-platform are we using here to get react-hooks?

These are the available themes.

➜  EXAMPLES bsb -themes
Available themes:
basic
basic-reason
generator
minimal
node
react
react-lite
tea

I believe its available out of the box now, isnt it? So maybe -theme react should work except in version 6.0 which is bs-platform@next

This gist has the package.json/bsconfig.json that worked for me in case it helps. gist.github.com/idkjs/e9273ac0e2a5...

Collapse
 
iwilsonq profile image
Ian Wilson

should be bs-platform@5.0.1 for the react-hooks theme, I just upgraded with npm i -g bs-platform.

Apart from the theme, if you're rolling reason-react@0.7.0, you should have the bindings to hooks so you can use any theme you'd like. I usually sacrifice the boilerplate anyways 😅

Collapse
 
idkjs profile image
Alain

Also didnt work until i updated react-jsx": 2 to react-jsx": 3 in bsconfig.json. Just trying stuff....

Collapse
 
kayis profile image
K

The last update to ReasonReact really makes components as concise to write as it should be :)

Collapse
 
iwilsonq profile image
Ian Wilson

Semantically speaking, yes that is a side effect. I feel like if I were calling React.useState(() => 0) instead in order to manage my "ticks" I would use useEffect and pass that ticks variable as a dependency.

When calling useReducer like this, you can imagine the Tick action as the dependency with the reducers switch expression as the effect execution.

Before the hooks version of ReasonReact, you would update a ReasonReact reducer by calling ReasonReact.UpdateWithSideEffects - meaning your effects were also performed in the reducer.

See this section for what that used to look like github.com/reasonml/reason-react/b....

Hope that makes sense!

Collapse
 
efleurine profile image
Emmanuel

Thanks for this great and detailed example.

Collapse
 
masiucd profile image
Marcell Ciszek Druzynski

Ian thank you for this. Great post just what I wanted!

Collapse
 
alfieqashwa profile image
alfieqashwa

where is the source code ?

Thanks

Collapse
 
iwilsonq profile image
Ian Wilson

Hi, thank you for the reminder, an oversight by me. Here it is!

github.com/iwilsonq/pomodoro-timer...

Collapse
 
idkjs profile image
Alain

Absolutely fantastic. Woke up this morning having to get Hooks into my head and fingers. As a bonus, got updating tab with document.title and updating styles with hooks. Thanks, brother.

Collapse
 
sophiabrandt profile image
Sophia Brandt

Thanks for the tutorial. Nice simple app to get your feet wet with ReasonReact.
I liked seeing how you can update the browser's title.

Collapse
 
deanius profile image
Dean Radcliffe

They've chosen an unfortunate name - why would I want another b.s. platform haha 🤣
Nice post though, this is the gentle introduction to ReasonML for React Developers I've wanted.

Collapse
 
iwilsonq profile image
Ian Wilson

Thank you~ I'll ask the authors if i ever get to meet them. My guess is they chose it because it would give newcomers something to giggle about