DEV Community

loading...

Reason(React) Best Practices - Part 3

Florian Hammerschmidt
If it compiles, it works!
ใƒป7 min read

Welcome back to the third part of my blog mini series (Part 1, Part 2) which derived from my talk at @reasonvienna. This part is all about writing zero-cost bindings to existing JavaScript functions and React components.

Background

Since ReasonReact included Hooks support starting with version 0.7.0, writing React in Reason has become a breeze. The library now became a very thin layer between React and BuckleScript and as a result, we did not need to write tedious boilerplate code anymore (mostly to map to the behaviour of class-based React components). But before the old way gets completely lost in history, here is a small reminder how we wrote React in ReasonML just some months ago:

let component = ReasonReact.statelessComponent("Greeting");

let make = (~name, _children) => {
  ...component,
  render: self =>
    <h1> {ReasonReact.string("Hello " ++ name)} </h1>,
};
Enter fullscreen mode Exit fullscreen mode

vs.

/* Greeting.re */
[@react.component]
let make = (~name) => <h1> {React.string("Hello " ++ name)} </h1>
Enter fullscreen mode Exit fullscreen mode

To be fair, some magic happens in the react.component annotation, but it was just more work to create a stateless component in which we needed to spread our component definition into. Not to speak of comparing stateful components, the differences are humungous. For compatibility reasons, we still have the old module (ReasonReact), but the new one (just React) is about a third of the size of the old one.

Penalty-free bindings

Some of the most important differences though, are the implications on performance. Whereas the old ReasonReact comes with its own React Mini to be compatible with class-based components, the new one is meant to be a very thin layer between React and ReasonML, (mostly) without any additional performance cost. To be fair, this is only true if you use the Hooks API. If you rather write class components, then you still have to use the old approach (but then you probably would not use the FP language Reason either).

This idea also spilled over to the React Native bindings, which are mostly done at this point in time (only some of the more obscure or deprecated ones are missing).

I think this attitude is a good one to have, because we do not want to lose performance-wise against plain JS - it would make newcomers more hesitant to dive into the ReasonML ecosystem. Even better: by using the right annotations, you get the most out of your bindings and win some developer experience too. For instance if you take [@bs.string] which converts plain string enums into polymorphic variants (the ones which start with a `backtick) and in consequence make the compiler restrict your code to using only those variants.

In the following section, I will examine one of the well-written React-Native bindings, by using example apis or components directly from the sources.

React-Native Alert

The Alert API consists of two callable methods, alert and prompt. While prompt actually does only trigger on iOS devices (and is not even mentioned in the official React Native docs, as of yet), we still want to bind that too for educational purposes. If you look at the JS sources of Alert, you will notice the Flow types which make it pretty straightforward to translate everything to Reason code.

Note 1: As the following section contains both JS and ReasonML code, a small comment header will tell you what language I am talking about.

Note 2: The Reason source code may be formatted differently than what refmt would put out. I adapted it for readability and conciseness.

The first type is AlertType. It is a string enum consisting of

/* JS */
  | 'default'
  | 'plain-text'
  | 'secure-text'
  | 'login-password';
Enter fullscreen mode Exit fullscreen mode

this translates to

/* Reason */
type alertType: [@bs.string] [
              | `default
              | `plainText
              | `secureText
              | `loginPassword
            ];
Enter fullscreen mode Exit fullscreen mode

Note that you cannot use a hyphen in polymorphic variants, so there is another step necessary:

/* Reason */
type alertType = [@bs.string] [
              | `default
              | [@bs.as "plain-text"] `plainText
              | [@bs.as "secure-text"] `secureText
              | [@bs.as "login-password"] `loginPassword
            ];
Enter fullscreen mode Exit fullscreen mode

[@bs.as] takes care of that. Generally, when there are hyphens, special characters, or numbers at the start, this approach is needed.

There is also the AlertButtonStyle, which can be translated in the same way:

/* JS */
export type AlertButtonStyle = "default" | "cancel" | "destructive";
Enter fullscreen mode Exit fullscreen mode

translates to

/* Reason */
type alertButtonStyle = [@bs.string] [ | `default | `cancel | `destructive],
Enter fullscreen mode Exit fullscreen mode

Then there is Buttons, an array of Button. I think it is better to define a button type and then define a buttons array type instead of mixing them all into one type definition, so let's pretend that the JS types are also written like that:

/* JS */
export type Button = {
  text?: string,
  onPress?: ?Function,
  style?: AlertButtonStyle
};
Enter fullscreen mode Exit fullscreen mode

becomes

/* Reason */
type button; // create abstract button type

[@bs.obj]
external button: // define a translator function for the button type
  (
    ~text: string=?,
    ~onPress: unit => unit=?,
    ~style: alertButtonStyle=?,
    unit
  ) =>
  button = // returns a button
  ""; // dummy placeholder
Enter fullscreen mode Exit fullscreen mode

This one is more complex, so we use [@bs.obj] here. It lets us define a function from which the compiler derives a JS object. This is useful if you have many optional parameters, as it is the case with the button above. The =? after the corresponding type denotes that the function is not required.

Then there is the options type:

/* JS */
type Options = {
  cancelable?: ?boolean,
  onDismiss?: ?() => void
};
Enter fullscreen mode Exit fullscreen mode

which works the same as the button above:

/* Reason */
type options;

[@bs.obj]
external options:
  (
    ~cancelable: bool=?,
    ~onDismiss: unit => unit=?,
    unit
  ) => options = "";
Enter fullscreen mode Exit fullscreen mode

Note that the type information of onDismiss is much clearer to translate - an empty function body yielding void which becomes unit => unit in Reason land. For translating ?Function one needs to look up the flow docs which say Function is basically any, so we only know what it really returns by using it and logging things out or digging through the source code. But in this case, we know it is still unit => unit.

The final piece is the signature of the method itself. As we speak of a JS class component, the alert method's signature can be found under static alert(...):

/* JS */
static alert(
  title: ?string,
  message?: ?string,
  buttons?: Buttons,
  options?: Options,
): void
Enter fullscreen mode Exit fullscreen mode

which translates to

/* Reason */
[@bs.scope "Alert"] [@bs.module "react-native"]
external alert:
  (
    ~title: string,
    ~message: string=?,
    ~buttons: array(button)=?,
    ~options: options=?,
    unit
  ) => unit = "";
Enter fullscreen mode Exit fullscreen mode

The most important part here is the first line - we need [@bs.module] to tell the compiler that we want to call a method from an external module, in this case from "react-native". Also we want to look it up under the Alert module of React Native. Therefore we utilize [@bs.scope] with "Alert". Remember, this is the equivalent of doing

import { Alert } from "react-native";
Enter fullscreen mode Exit fullscreen mode

in JavaScript and I think that it is mapped pretty well that way.

The signature of the prompt method is also typed:

  static prompt(
    title: ?string,
    message?: ?string,
    callbackOrButtons?: ?(((text: string) => void) | Buttons),
    type?: ?AlertType = 'plain-text',
    defaultValue?: string,
    keyboardType?: string,
  ): void
Enter fullscreen mode Exit fullscreen mode

This also looks doable:

  • title and
  • message work the same way as in the alert method,
  • defaultValue and
  • keyboardTypes are just strings,
  • type is already typed above, known as alertType.

So easy going, right? But what abomination of a property is callbackOrButtons? This is a JavaScript'ism which I would even call an antipattern in ReasonML. In the Reason (and FP) world, you would rather statically check your types and not inspect whether your prop is an array and do something differently than what you would have done with a function, all at runtime. This is such a thing which can only be done in a dynamically typed language. *hissing snake noises*

But be assured, that even for such cases, BuckleScript provides a wonderful remedy: [@bs.unwrap]. It utilizes polymorphic variants which all get compiled away, so we don't lose our precious performance. We just have to create two of them, one for `callback and one for `buttons. The button type has been defined already above and the callback just translates easily from Flow again, string => void becomes string => unit in Reason land.

    ~callbackOrButtons: [@bs.unwrap] [
                          | `callback(string => unit)
                          | `buttons(array(button))
                        ]=?,
Enter fullscreen mode Exit fullscreen mode

So we end up with this:

[@bs.scope "Alert"] [@bs.module "react-native"]
external prompt:
  (
    ~title: string,
    ~message: string=?,
    ~callbackOrButtons: [@bs.unwrap] [
                          | `callback(string => unit)
                          | `buttons(array(button))
                        ]=?,
    ~type_: [@bs.string] [ /* alertType */
              | `default
              | [@bs.as "plain-text"] `plainText
              | [@bs.as "secure-text"] `secureText
              | [@bs.as "login-password"] `loginPassword
            ]=?,
    ~defaultValue: string=?,
    ~keyboardType: string=?,
    unit
  ) => unit = "prompt";
Enter fullscreen mode Exit fullscreen mode

Of course writing bindings is only half the fun, so here's is an example of how you would use the Alert.alert binding:

Alert.alert(
  ~title="Warning!",
  ~message="Do you want to delete the entry?",
  ~buttons=[|
    Alert.button(~text="Delete", ~onPress, ~style=`destructive, ()),
    Alert.button(~text="Cancel", ~style=`cancel, ()),
  |],
  ~options=
    Alert.options(
      ~cancelable=false,
      ~onDismiss=_ => Js.log("Deletion aborted."),
      (),
    ),
  (),
);
Enter fullscreen mode Exit fullscreen mode

And here's is an example of how you would use the Alert.prompt binding (again, only on iOS):

Alert.prompt(
  ~title="Enter Password",
  ~message="Please enter your password.",
  ~callbackOrButtons=`callback(text => Js.log(text)),
  ~type_=`secureText,
  (),
);
Enter fullscreen mode Exit fullscreen mode

Don't forget to call all the defined externals with their Module name (here Alert) before or open it in the scope.

Speaking of externals, a good rule of thumb to know whether you created some runtime overhead with your bindings, is the following:

Have a look if there are any lets in your code, rather than externals.

To be sure look into your created .bs.js bindings file. When you only see

/* This output is empty. Its source's type definitions, externals and/or unused code got optimized away. */
Enter fullscreen mode Exit fullscreen mode

you can pat yourself on the shoulder, because you successfully created a zero-cost binding!

That's all for my mini series about Best Practices in Reason & ReasonReact, at least for now. Any upcoming posts will not be part of this series anymore, but they will almost certainly contain ReasonML stuff.

So have a great time, and make great (type-safe) things!

Discussion (12)

Collapse
idkjs profile image
Alain • Edited

Please don't let this be all, brother. I have tried at various times to write bindings to the github.com/developit/mitt library for no other reason than its super short and should be doable as a learning experience. Why not a bonus post in this series binding that library?!! Thanks for sharing your knowledge here. This has been invaluable and a go to series for me.

Peace to you.

Collapse
idkjs profile image
Alain • Edited

This is where I keep getting stuck.

I can't figure out how to bind to the star symbol and anything else really.
Js code is:

type EventHandlerMap = {
  '*'?: WildCardEventHandlerList,
  [type: string]: EventHandlerList,
};

and can be found here:github.com/developit/mitt/blob/2ab...

Collapse
yawaramin profile image
Yawar Amin • Edited

Hey Alain, roughly speaking this should work:

module EventHandlerMap = {
  type t;
  type wildCardEventHandlerList;
  type eventHandlerList;

  [@bs.get] external star: t => option(wildCardEventHandlerList) = "*";
  [@bs.get_index] external get: (t, string) => option(eventHandlerList) = "";
};

let f(eventHandlerMap) = EventHandlerMap.star(eventHandlerMap);
let g(eventHandlerMap) = EventHandlerMap.get(eventHandlerMap, "foo");

It models the EventHandlerMap and its values as abstract types, you can fill in more details if you know them.

[EDIT: I made the get return an option because a dynamic key lookup may always return undefined.]

Thread Thread
fhammerschmidt profile image
Florian Hammerschmidt Author • Edited

Yawar, you really are an OCaml/Reason guru and probably the most helpful guy I ever met in any community. Keep it up!

Thread Thread
yawaramin profile image
Yawar Amin

Aw, shucks ๐Ÿ˜Šit's very gratifying to see people get pulled into the ReasonML community/tech ecosystem and make cool stuff. And one of these days, I'll manage to ship some Reason at work tooโ€“fingers crossed ๐Ÿ˜

Collapse
fhammerschmidt profile image
Florian Hammerschmidt Author

Ahhh, that's a tough one. But the library is short enough that I would rewrite in Reason completely. Many JS hacks though (I suppose for performance).

Having a Map of different types is just not possible (IMHO) in Reason. You'd need a wrapper EventHandlerList type which works for both WildCardEventHandlerList and EventHandlerList.

Sometimes, you just need to use plain JS Objects and Obj.magic, I guess, sorry.

Collapse
fhammerschmidt profile image
Florian Hammerschmidt Author

As said, there will be more - just not under the "Reason(React) Best Practices" umbrella. I want to cover all of the bs.* annotations at some point.

Collapse
idkjs profile image
Alain

Question:

When I through the alertType into an editor:

type alertType = [@bs.string] [
              | `default
              | [@bs.as "plain-text"] `plainText
              | [@bs.as "secure-text"] `secureText
              | [@bs.as "login-password"] `loginPassword
            ];

I get bucklescript warnings on bs.string and bs.as.

alertType screenshot

Why is that?

Collapse
fhammerschmidt profile image
Florian Hammerschmidt Author

Hm, it's actually only for demonstration purposes. bs.string only works for arguments of functions. That's why it is inlined in the sources.

Collapse
hodatorabi profile image
hodatorabi

Hi thanks for sharing this. I haven't actually run this code but when trying to do something similar with bs.unwrap and labelled arguments I ran into github.com/BuckleScript/bucklescri... Should I be doing something differently?

Collapse
fhammerschmidt profile image
Florian Hammerschmidt Author • Edited

So bs.unwrap does unfortunately not work with @bs.obj. This means it also does not work with [@react.component] either, which uses @bs.obj internally, if you stumbled over that.

It works perfectly in the source code of the example which does not use @bs.obj.

If you are on BuckleScript 7.1 or higher, there is an alternative, though: [@unboxed]:

For instance if you take the callbackOrButtons from above:

type button;

module CallbackOrButtons = {
    [@unboxed] 
    type t =
        | Any('a): t;

    let makeCallback: (. string => unit) => t = (. s) => Any(s);
    let makeButtons: array(button) => t = i => Any(i);
};

/* The Obj.magic is only for demonstration purposes, 
   I did not want to type out the button type. */
let buttons = CallbackOrButtons.makeButtons([|"<Button/>"->Obj.magic|])
let callback = CallbackOrButtons.makeCallback(. a => Js.log(a))

then you would call ~callbackOrButtons=buttons or ~callbackOrButtons=callback in the binding from the article.

Collapse
hodatorabi profile image
hodatorabi

Thank you, I will definitely try this.