Over the last 5 months, we at Codeheroes had a chance to work on a mobile application for both iOS and Android as well as web-based panel providing "admin" functionality for a mobile app. We always want to push our skills further and create better and more reliable software with every next project so we went with ReasonML for both applications.
If you want to learn more about Reason I highly recommend official documentation and a great book by Dr. Axel Rauschmayer - Exploring ReasonML and functional programming
I had prior experience with Reason but the rest of the team (one junior and two developers with about two years experience each) had only worked with typed JavaScript using Flow, React and React Native. This project was not only our first Reason project as a team (previously I was writing parts of another app in Reason to test if it suits our needs) but also I had to learn and support other developers on their Reason path.
I want to share our experience and lessons learned from creating mobile and web apps in ReasonML!
Why Reason?
We believe in a statically typed and functional approach as we worked with Flow previously. We also try to write software in an as much functional way as it makes sense in JavaScript. Additionally, our backend is written in Scala so our Backend Developers influence our way of thinking a lot.
I was thinking a lot about introducing Elm to our projects. The language is very hermetic and in my opinion, it would be impossible to create a project using all our previous React experience and technology we developed over the years.
When I learned about Reason somewhere in 2017 it was not mature enough to use it in production then. But in 2018 a lot of Reason community effort with tooling, bindings, talks, blog posts made the language mature enough to test it as a part of our existing JavaScript application. After that, as it went well it convinced us it's reasonable to go with Reason for the whole project.
In case of some real problems, we knew we can go back to JavaScript to finish the project on time. Fortunately, we hadn't had to do so. The last 5 months only convinced us it was a really good decision.
One of the things we were afraid of was interoperability with JavaScript...
1. Don't be afraid of bindings
The core functionality of the application is a medical Bluetooth device for women. They can connect the device to application and "play" simple games using their pelvic muscles.
We knew we had to use a few libraries that don't have existing bindings. The two most significant were: react-native-ble-plx which we used for Bluetooth connection and react-native-game-engine which provides nice API we used for creating games controlled by Bluetooth device.
Bindings forced us to define types for every function we used. It's the same story as Flow or Typescript type definitions but it gives us much more!
In Flow you can omit types, you can do whatever you want and if you decide to write types (or use already existing types from flow-typed) they can lie to you not reflecting the real API. They are not implementation they just type annotations. In Reason, you have to create bindings which are both type annotations and actual implementation of how we want to connect to existing JavaScript code. Of course, you can create bindings that lie about the API but it comes a lot quicker during development in Reason than in Flow.
You can disagree with me 😉.
Another cool Reason feature are abstract types. Those are types that don't have an internal structure. You define them as follows:
/* abstract type */
type someComplexJavaScriptType;
/* normal record type */
type person = {name: string, age: int};
You can create a type that is only passed from function to function. You don't have to care if it's a string, int or some complex object, and what field it has. It exists only in bindings. Here is an example of react-native-ble-plx bindings using abstract types:
type bleManager;
[@bs.module "react-native-ble-plx"] [@bs.new]
external createBleManager: unit => bleManager = "BleManager";
type subscription;
[@bs.send] external removeSubscription: subscription => unit = "remove";
[@bs.send]
external onStateChange:
(bleManager, string => unit, bool) => subscription = "onStateChange";
First, we define bleManager
type and don't care about its internals. Then we have a function to create it from thin air. Great. The same goes with subscription
type. We know onStateChange
function returns subscription but we don't have to care what it is. We need only to know there is removeSubscription
function to remove a subscription.
Once we're forced to create those bindings (and the process of writing them is not hard nor time-consuming) we have a nice place to slightly adjust the library to our needs. We can create an intermediate layer in which we can, for example, rearrange function arguments order for fast-piping or make them labeled, we can compose one or more functions together, we can model the API for our application use case. Without bindings, API we created around those libraries would be much worse.
Of course, this makes it harder to share bindings as an open-source project but I think it should never be the primary goal if you're forced to create bindings in your application code.
It's great to do that so others can benefit but I believe it's the second step. First, create bindings for any functionality you need, then write your application using them so you can validate if they are any good, then publish it as open-source and share with the community to gather feedback from other developers.
2. Render props, using let-anything and bs-epitath, are awesome
Render props is popular React pattern. It allows you to reuse component logic in multiple places. A popular use case is react-apollo
query component allowing you to create GraphQL queries. The problem is it makes your component bigger and harder to read. If you have one such component it's ok but if you have two or more you are creating that nested callback monster.
PPX to the rescue! PPX is something called a syntax rewriter. It's OCaml/ReasonML compiler extension which allows you to create compile-time macros.
One of such is let-anything - created by Jared Forsyth. Together with bs-epitath from Astrocoders, it gives us real superpower. Let's look at React Context render props example:
/* without let-anything and bs-epitath */
let component = ReasonReact.statelessComponent(__MODULE__);
let make = _ => {
...component,
render: _ => {
<Context.Consumer>
...{context =>
<BsReactNative.Text>
{ReasonReact.string("Logged as: " ++ context.user)}
</BsReactNative.Text>
}
</Contect.Consumer>
},
};
/* with let-anything and bs-epitath */
let component = ReasonReact.statelessComponent(__MODULE__);
let make = _ => {
...component,
render: _ => {
let%Epitath context = children =>
<Context.Consumer>
...children
</Contect.Consumer>;
<BsReactNative.Text>
{ReasonReact.string("Logged as: " ++ context.user)}
</BsReactNative.Text>;
},
};
Sweet, right?
But be aware! We fall in the trap of overusing it in large components with not only data render props components but combined with view components.
Latest changes to bs-epitath will make it almost impossible to write such code as presented below. But since they want to support old and new syntax please be cautious!
/* don't do this, please */
let component = ReasonReact.statelessComponent(__MODULE__);
let make = _ => {
...component,
render: _ => {
let%Epitath context = children =>
<Screen>
<Section>
<Card>
<Context.Consumer>
...children
</Contect.Consumer>
</Card>
</Section>
</Screen>;
/* real part of the component */
},
};
While it's valid to do so, I think it introduces a lot of indirection and makes things harder to read. If you want to learn more, Gabriel Rubens Abreu wrote a great post - Render Props composition for ReasonML is here that describes the concept in details.
When starting with a new language, it's often difficult to learn best practices and deeply understand how to model your application. It was true for us, and we learned about it when working on the core functionality of the app...
3. Create more modules hiding your implementation details.
When we created functionality around Bluetooth connection we had to gather samples send by the device. We used them for controlling games and for sending them to our backend for further analysis. While sending samples to the backend is rather easy and requires little or no interaction with samples, the game part is rather complex as we want to process samples in various ways.
Iterate over samples applying some transformation to part of the samples, get samples in some timeframe, find spikes in a list of samples and much, much more.
We failed but not creating a dedicated Sample
module. It should have sample
type and all functions we would like to use across the whole application. It was a really bad decision that impacted the development of every part relying on that data. We had functions in many modules, many implementations of the same or similar function made by different developers. In general, it was a mess.
Lesson learned here - create modules for your data types to hide the implementation. Let's assume you have a simplified sample that consists of a timestamp and some value gathered in that time. Example module would look something like this:
/* Sample.re */
type t = (float, float);
type samples = list(t);
let make = (time, value) => (time, value);
let getTime = sample => sample->fst;
let getValue = sample => sample->snd;
let mapTime = (sample, fn) => {
let (time, value) = sample;
(fn(time), value);
};
let mapValue = (sample, fn) => {
let (time, value) = sample;
(time, fn(value));
};
/* more complex functions... */
Later, you decide that tuple is not a suitable data structure for your use case and you want to change it. Let's assume record because you have more than two elements tuple. You change only lower-level functions interacting with the type t
and everything works as expected. No need for going through every module using Sample
. One file, one commit. Done.
/* Sample.re */
type t = {time: float, value: float};
type samples = list(t);
let make = (time, value) => {time, value};
let getTime = sample => sample.time;
let getValue = sample => sample.value;
let mapTime = (sample, fn) => {
{...sample, time: fn(sample.time)};
};
let mapValue = (sample, fn) => {
{...sample, value: fn(sample.value)};
};
/* other functions... */
This is a simple example that was most significant to us and was probably the worst decision in the whole development but we learned a lot from that and wouldn't make the same mistake twice.
4. react-navigation is hard, but with Reason, it's not that hard
In our previous React Native application, we had a hard time around react-navigation. It's really hard to make it statically typed and sound in Flow. Making refactor to screen names and props passed between screens caused us a lot of headaches during the development of a previous application.
When we started work on this application I was a little worried about how it would go. There were no good bindings to react-navigation at a time. But thanks to initial work on bs-react-navigation by guys at Callstack we had something we could work on.
Here are three parts making whole navigation a lot easier.
- Explicitly passed navigation to every screen which wants to change currently active screen
- Variant describing all available screens and their params
getScreen
function rendering currently active screen based on screen variant
A simplified version looks something like this:
/* our screen type */
type screen =
| Products
| Product(string);
/* get screen function rendering currently active screen */
let getScreen = (route, navigation) => {
switch(route) {
| Products =>
(
<ProductsScreen navigation />,
screenOptions(~title="Products", ())
)
| Product(id) =>
(
<ProductScreen id navigation />,
screenOptions(~title="Product", ())
)
};
};
/* example screens creating our application */
module ProductsScreen = {
let component = ReasonReact.statelessComponent(__MODULE__);
let make = (~navigation, _) => {
...component,
render: _ =>
<BsReactNative.ScrollView>
<Product onPress={id => navigation.push(Product(id))} />
/* other products... */
</BsReactNative.ScrollView>
};
};
module ProductScreen = {
let component = ReasonReact.statelessComponent(__MODULE__);
let make = (~id, ~navigation, _) => {
...component,
render: _ =>
/* product query */
<BsReactNative.View>
/* product details... */
</BsReactNative.View>
};
};
It makes it almost impossible to pass wrong params, forgot about something and in the process of adding more params or changing existing routes
you know every place you have to adjust. And if you make them, the compiler tells you what's wrong and as soon as it compiles - in 99% of cases it works as expected.
5. Don't be afraid of polymorphic variants.
We used graphql_ppx and reason-apollo for GraphQL client-server communication. In our schema, we have a lot of GraphQL Enum types. From things like application locale to things like available games and their configurations. In graphql_ppx generated code enums are polymorphic variants.
When we started writing application we were "scared" of polymorphic variants and decides to create normal variant types for each enum we use in our GraphQL schema. We created a module for each one with a bunch of functionality for converting them back and forth. We had to convert them to every place of the application. From polymorphic variant to variant, from variant to string, from variant to i18n message. It was a lot of duplicated logic only because we were afraid to use polymorphic variants. And what about adding or removing something in API? We had to change our types twice, both polymorphic variants and normal variants.
We learned polymorphic variants are just as fine as normal variants. They give you fewer guarantees and code is harder to debug since compilation errors can pop up in strange places not directly related to please when you use them. Despite all that you shouldn't be afraid. But remember, they are more expensive than normal variants, so use them with caution after all 😉.
My rule of thumb is - if you only pass some variant from the API to the view, it's totally fine to use a polymorphic variant. If they live only in your application (like in navigation) it's better to use normal variants.
Community is great, open-source is great!
Without great projects like bs-react-native, re-formality, graphql_ppx and reason-apollo, reason-apollo, bs-react-navigation and bs-react-intl our work would be much harder or maybe even not possible. We wouldn't choose to go with Reason without those libraries. I want to thank everyone involved in making those libraries and other open-source libraries we used.
If you want to talk more about our work or Reason, DM me on Twitter!
Top comments (9)
Elm is not hermetic. Elm operated very well with JavaScript. In my opinion for the most part, elm and Reason are the same.
I'm not saying Elm is a bad language. It was a first pure functional language I learned and I would love to create a productions system in it but it's a lot harder to create something from scratch as the community is small.
is ReasonML a much more mature than Elm? Yes, Elm has a very small community. In The are still a lot of change occurring for each version of Elm.
ReasonML as a language is as mature as OCaml. OCaml is 24 years old.
In Elm, you cannot reuse you're existing code without creating ports for that. And Elm uses it's own view framework so you rely only on Elm community components. In Reason, you can reuse all of the JS ecosystems 😉
Cool, thanks for sharing!
How long new team member spend to get into the project in ReasonML?
It's a constant process. If someone had previous experience with Flow/Typescript I would say it's a matter of a week or something like this. If someone is an experienced React developer it's not a problem. Of course, when starting someone will copy patterns from JS which not always work in Reason but over time more and more things will come naturally. Documentation can be a hard thing but projects like reasonml.org/ will change the game for newcomers.
Thanks!
Nice post @Tomasz, keep us updated with how the team approaches new Reason projects!