1st June 2019 • 8 min read
ReasonML for production React Apps? (Part 4)
Seif Ghezala
This is the last article of the series. We've built a few things with ReasonReact and now it's time to share my opinion about using Reason to create React applications.
Access Part 1 here, Part 2 here and Part 3 here.
Though, my opinion shouldn't really matter if you're also evaluating ReasonML. That's why I will share an approach that should help you decide whether to use something in production or not.
We will also see 5 tips that I learned while creating this series and that are very useful when building applications with ReasonReact.
Type coverage
Ensuring a good type coverage at compile time matters because it makes our code more reliable. A bug happens when the application behaves differently from the way we intended it to behave. Type coverage forces us to be very explicit about that behavior at compile-time, which is also at "code-time" (the time you're implementing it). It's true, not all bugs are related to type. However, the more explicit we are about typing values, the more we can delegate the work of checking for bugs (testing) to the compiler itself.
A side effect of having your code statically typed is enhancing its readability. Code editors and syntax plugins can use the static type information provided by the compiler and give you hints about the code you're reading. The bigger the codebase, the more you actually appreciate that.
Development speed
The speed at which we deliver features is definitely a metric of our efficiency that shouldn't be ignored. In some contexts, it's even the first priority.
Development speed also matters because it's an important factor in the developer's experience. When a tool makes it easy to implement something fast, it's often more accessible to people and also more adopted. This is simply because most of us enjoy the results of what we build, and want to get to them as fast as possible.
So, how to decide?
When picking a tool that you will use every day to build things, it's important that you consider both type coverage and development speed.
Ideally, we would have this:
- Type coverage: ██████████ 100%
- Development speed: ██████████ 100%
Unfortunately, this is unrealistic.
JavaScript is amazing when it comes to dev speed. The language is super dynamic and this can be used to achieve things fast with just a few lines of code:
Here's a one-liner concatenation function:
1let concat = (a, b) => a + b;
2// concatenate strings
3concat("Hello ", "World"); // output: "Hello World"
4
5// concatenate strings with numbers
6concat("hello", 3); // output: "Hello 3
However, JavaScript also doesn't provide any of the predictability & readability benefits we get with static type coverage.
My verdict
I started the series knowing already that ReasonML is for sure around a 💯 score when it comes to type coverage.
Though, my past experience with the library made me very skeptical about the development speed. This was confirmed when I faced certain challenges like:
- React Context API.
- Async requests.
- Deserializing JSON.
However, the new syntax of ReasonReact made the development speed jump to a really high score. We're definitely not at JavaScript's dev speed score, but we're not far either. In fact, the issues I mentioned will not block you when creating applications in production. This is only possible thanks to ReasonML's flexibility and community.
This is great because we have a tool to build React application that provides a very powerful type of coverage without hurting the development speed.
In the next session, I put some tips provided by the Reason community to solve those issues.
Tip #1: React Context
To create & use a React Context, we have to wrap the Context provider in a custom component:
1/* MyContextProvider.re */
2let context = React.createContext(() => ());
3let makeProps = (~value, ~children, ()) => {
4 "value": value,
5 "children": children,
6};
7
8
9let make = React.Context.provider(context);
We can then use the created Context provider as follows:
1[@react.component]
2let make = (~children) => {
3<MyContextProvider value="foo">
4 children
5 </MyContextProvider>
6}
7
8
9module ChildConsumer = {
10[@react.component]
11let make = (~children) => {
12 let contextValue = React.useContext(MyContextProvider.context);
13};
Tip #2: requiring CSS
BuckleScript provides ways for requiring a JavaScript module without sacrificing type safety. However, when we require a CSS file, we don't really need any typing. Therefore, we can directly use BuckleScript's syntax for embedding raw JavaScript and write a normal JavaScript require statement:
1[%raw {|require('path/to/myfile.css')|}];
Tip #3: using JavaScript React components
Here's an example on how to consume an existing JavaScript React component, without hurting type safety:
1[@bs.module "path/to/Button.js"] [@react.component]
2external make: (
3 ~children: React.element,
4 ~variant: string,
5 ~color: string,
6 ~onClick: ReactEvent.Form.t => unit
7) => React.element = "default";
Using SVGR
SVGR is a great tool that lets you automatically transform SVG into React components.
You can use the previous tip to automatically and safely import SVG components as React components through SVGR:
1[@bs.module "./times.svg"] [@react.component]
2external make: (~height: string) => React.element = "default";
Make sure to install the corresponding Webpack loader and add the necessary Webpack configuration.
Tip #4: performing Fetch network requests
To perform network requests from a React application, we need to use Fetch.
Here's an example on how you can make your own wrapper on top of Fetch to make POST requests:
1let post = (url, payload) => {
2 let stringifiedPayload = payload |> Js.Json.object_ |> Js.Json.stringify;
3
4
5Js.Promise.(
6 Fetch.fetchWithInit(
7 url,
8 Fetch.RequestInit.make(
9 ~method_=Post,
10 ~body=Fetch.BodyInit.make(stringifiedPayload),
11 ~headers=Fetch.HeadersInit.make({"Content-Type":
12 "application/json"}),
13 (),
14 ),
15 )
16 |> then_(Fetch.Response.json)
17);
18};
You can adjust this wrapper for other types of requests.
Tip #5: Handling JSON
Reason still doesn't have proper built-in JSON handling. In Part 2 of the series, I managed to deserialize a JSON response without using any third-party library:
1/* src/Request.re */
2
3exception PostError(string);
4
5let post = (url, payload) => {
6 let stringifiedPayload = payload |> Js.Json.object_ |> Js.Json.stringify;
7
8 Js.Promise.(
9 Fetch.fetchWithInit(
10 url,
11 Fetch.RequestInit.make(
12 ~method_=Post,
13 ~body=Fetch.BodyInit.make(stringifiedPayload),
14 ~headers=Fetch.HeadersInit.make({"Content-Type": "application/json"}),
15 (),
16 ),
17 )
18 |> then_(Fetch.Response.json)
19 |> then_(response =>
20 switch (Js.Json.decodeObject(response)) {
21 | Some(decodedRes) =>
22 switch (Js.Dict.get(decodedRes, "error")) {
23 | Some(error) =>
24 switch (Js.Json.decodeObject(error)) {
25 | Some(decodedErr) =>
26 switch (Js.Dict.get(decodedErr, "message")) {
27 | Some(errorMessage) =>
28 switch (Js.Json.decodeString(errorMessage)) {
29 | Some(decodedErrorMessage) =>
30 reject(PostError(decodedErrorMessage))
31 | None => reject(PostError("POST_ERROR"))
32 }
33 | None => resolve(decodedRes)
34 }
35 | None => resolve(decodedRes)
36 }
37
38 | None => resolve(decodedRes)
39 }
40 | None => resolve(Js.Dict.empty())
41 }
42 )
43 );
44};
Though, I wasn't satisfied with the solution since it resulted in a huge pattern-matching hell.
Since then, and with the help of the community, I found some nice alternatives using thrid-party libraries.
bs-json
Using bs-json, you can achieve the same result in a much concise way. The goal is to use bs-json to convert our JSON into records. We first declare our record types. In our case, we needed to handle the response JSON object, which has optionally an error JSON object. We can do it as follows:
1type error = {message: string};
2type response = {
3 error: option(error),
4 idToken: string,
5};
We can then create functions to decode the JSON objects (response & error):
1module Decode = {
2 let error = json => Json.Decode.{message: json |> field("message", string)};
3
4
5let response = json =>
6 Json.Decode.{
7 error: json |> field("error", optional(error)),
8 idToken: json |> field("idToken", string),
9 };
10};
Finally, we can easily decode the JSON we receive using our decoders:
1|> then_(json => {
2let response = Decode.response(json);
3 switch (response.error) {
4 | Some(err) => reject(PostError(err.message))
5 | None => resolve(response)
6 };
7})
ppx_decco
Another elegant way to achieve parsing JSON is to use the ppx_decco module.
We first declare our Records and prepend them with [@decco] decorator:
1[@decco]
2type error = {message: string};
3
4
5[@decco]
6type response = {error: option(error)};
This will create under the hood 2 functions we can use to deserialize the corresponding JSON values:
- error_decode
- response_decode
We can then use our declared Records & the created functions to easily decode the JSON values
1|> then_(response =>
2 switch (response_decode(response)) {
3 | Belt.Result.Ok({error: Some({message})}) =>
4 reject(PostError(message))
5 | response => resolve(response)
6 }
7)
This series aimed to give a realistic reflection of the ReasonML to build React applications. By building UI features that resemble the ones we would in usual production environments, we managed to grasp a good feel of both the good stuff and struggles you would face if you ever decide to use Reason in production. It's undeniable that Reason has a powerful type system with a very strong type inference that will make you write reliable code. With this series, we also saw how the development speed of React applications using Reason is also not affected. So, Yes, Reason is definitely ready to create React applications in production!
Special thanks to the Reason community on the Forums & Discord, and especially to @yawaramin for consistently reading the articles and providing help.