In this post, I'll show you how to write ReasonReact bindings for React.js components.
For all of you who don't know about ReasonML: ReasonML is syntactic sugar on top of the OCaml toolchain.
ReasonML/OCaml can be compiled into optimized Javascript with Bucklescript.
You can learn more about it by checking out this article by Dr. Axel Rauschmayer: What is ReasonML?
#The official way according to the docs
ReasonReact provides a great way to interop with React.js components using ReasonReact.wrapJsForReason
. Here is an example from the ReasonReact docs:
/* PersonalInformation.re */
[@bs.module] external jsPersonalInformation : ReasonReact.reactClass = "./PersonalInformation.js";
let make = (~name: string, ~age: option(int)=?, children) =>
ReasonReact.wrapJsForReason(
~reactClass=jsPersonalInformation,
~props={
"name": name,
"age": Js.Nullable.from_opt(age)
},
children
);
Now we'll use the above component like this:
<PersonalInformation name="Khoa Nguyen" age=Some(24) />
PersonalInformation.js
should be called with these props:
{
name: "Khoa Nguyen";
age: 24;
}
Everything is working as expected. Now let's try with slightly different props:
<PersonalInformation name="Khoa Nguyen" age=None />
PersonalInformation.js
should be called with these props:
{
name: "Khoa Nguyen";
age: undefined;
}
age
now has an undefined
value. It's not so bad in most cases, but it could problematic.
Here is how I implemented my PersonalInformation.js
component:
// PersonalInformation.js
import React from "react";
const PersonalInformation = props => {
let hasAge = props.hasOwnProperty("age");
if (hasAge) {
return (
<p>
My name is {props.name}. I'm {props.age} years old
</p>
);
}
return <p>My name is {props.name}</p>;
};
export default PersonalInformation;
I've created a CodeSandbox here for you to play around with.
With the the first example (~age=Some(24)
), it renders My name is Khoa Nguyen. I'm 24 years old as expected.
With the second example (~age=None
), however, it renders My name is Khoa Nguyen. I'm years old.
What is going on?
Let's open the node repl and try it out:
❯ node
> let firstExample = {name: "Khoa Nguyen", age: 24};
undefined
> firstExample.hasOwnProperty("age")
true
> let secondExample = {name: "Khoa Nguyen", age: undefined};
undefined
> secondExample.hasOwnProperty("age")
true
> "age" in secondExample
true
Ah. This totally makes sense. The age
property has a value of undefined
.
Quick note:
I know that the above code is not idiomatic React code. I can re-implement the React component like this to fix it:
<p>
My name is {props.name}.
{props.age && <span>I'm {props.age} years old</span>}
</p>
This should work even with age = undefined
, but the fact that I have to change the original component to write a binding isn't ideal. The pattern I use here (props.hasOwnProperty("age")
) is common for switching between controlled/uncontrolled mode of a component.
#"The right way" of writing ReasonReact bindings
Now we've identified our problem, let's fix it.
We need to find a way for not defining age
in props.
Lucky for us, Bucklescript provides a function called the Special Creation Function
The idea is simple:
[@bs.obj] external makeProps : (~name: string, ~age: int=?, unit) => _ = "";
let props1 = makeProps(~name="Khoa Nguyen", ~age=24, ());
let props2 = makeProps(~name="Khoa Nguyen", ());
Tips: You can try this on the playground at https://reasonml.github.io/en/try.html
This is the compiled code from Bucklescript:
var props1 = {
name: "Khoa Nguyen",
age: 24,
};
var props2 = {
name: "Khoa Nguyen",
};
As you can see, Bucklescript creates an object for us so no intermediate step is needed, and the age
property is not in props2
.
With this knowledge, we can rewrite our binding like this:
/* PersonalInformation.re */
[@bs.module] external jsPersonalInformation : ReasonReact.reactClass = "./PersonalInformation.js";
[@bs.obj] external makeProps : (~name: string, ~age: int=?, unit) => _ = "";
let make = (~name, ~age=?, children) =>
ReasonReact.wrapJsForReason(
~reactClass=jsPersonalInformation,
~props=makeProps(~name, ~age?, ()),
children
);
As you can see, I removed the type annotations in make
and put them in makeProps
. It's not necessary to annotate types everywhere with a great compiler. Now, our binding works as expected.
#Transform props
The above is a simple case. We sometimes need to apply some transformations to the props before passing them to React.js.
A common case might be transforming between Js.boolean
and ReasonML's boolean.
Here is a small snippet on how you can do that:
/* PersonalInformation.re */
[@bs.module] external jsPersonalInformation : ReasonReact.reactClass = "./PersonalInformation.js";
[@bs.obj] external makeProps : (
~name: string,
~age: int=?,
~showAge: Js.boolean=?,
unit
) => _ = "";
let make = (~name, ~age=?, showAge=?, children) =>
ReasonReact.wrapJsForReason(
~reactClass=jsPersonalInformation,
~props=makeProps(
~name,
~age?,
~showAge=?Js.Option.map([@bs] (a => Js.Boolean.to_js_boolean(a)), showAge),
()
),
children
);
You can use this to make your code more idiomatic ReasonML by replacing string enums with polymorphic variants:
[@bs.deriving jsConverter]
type theme = [
| `dark
| `light
];
[@bs.obj] external makeProps : (
~name: string,
~age: int=?,
~theme: string=?,
unit
) => _ = "";
let make = (~name, ~age=?, theme=?, children) =>
ReasonReact.wrapJsForReason(
~reactClass=jsPersonalInformation,
~props=makeProps(
~name,
~age?,
~theme=?Js.Option.map([@bs] (a => themeToJs(a)), theme),
()
),
children
);
I think this is enough for this blog post. If you want to see more example of the pattern on writing ReasonReact binding, checkout my Ant Design binding here: https://github.com/thangngoc89/bs-ant-design
This is my first time writing a blog post in English, so please let me know If I wrote anything that's incorrect.
Update 3/16/2018: If you're having troubles running the code in the example, you could try with this repository
You can reach me via Twitter(@thangngoc89) or @thangngoc89 on Discord channel.