Eryk Napierała
Posted on October 28, 2019
A couple of months ago React bindings for ReasonML language - commonly known as ReasonReact - has got support for Hooks API. With that update, a new version of JSX syntax came as well. We can now write our components in a completely new way! But how to get there, how to rewrite existing components gradually and without pain? Let's dive in!
The start
To keep things simple, let's assume we want to rewrite a single stateful component - a classical counter with two buttons. The only not usual thing about this component is that it displays the counter value using a function passed as children. It also shows a progress bar by simply printing dots in number equal to the counter value. You'll see later why those two things are interesting.
type action =
| Dec
| Inc;
let reducer = (action, state) =>
switch (action) {
| Dec => ReasonReact.Update(state - 1)
| Inc => ReasonReact.Update(state + 1)
};
let component = ReasonReact.reducerComponent("Counter");
let make = (~initialValue=0, children) => {
...component,
initialState: () => initialValue,
reducer,
render: self => {
let progress = Array.make(self.state, ReasonReact.string("."));
<>
<button type_="button" onClick={_ => self.send(Dec)}>
{"-" |> ReasonReact.string}
</button>
{children(self.state)}
<button type_="button" onClick={_ => self.send(Inc)}>
{"+" |> ReasonReact.string}
</button>
<pre> ...progress </pre>
</>;
},
};
The usage of this component may look like this.
<Counter initialValue=32>
...{num =>
" The world will end in "
++ string_of_int(num)
++ " years "
|> ReasonReact.string
}
</Counter>;
The component doesn't look impressive but is complex enough to present the most important concepts.
Migration script
ReactReason creators know very well how hard the migration of big codebases may be. That's why they provided migration scripts for each minor update of the library. This is the best possible starting point.
First, install the script (it will take a while, coffee time!)
npm i --no-save https://github.com/chenglou/upgrade-reason-react\#0.6.0-to-0.7.0
Then, execute it with a path to the file as an argument.
./node_modules/upgrade-reason-react/lib/bs/bytecode/migrate.byte Counter.re
Besides minor replacements, like {}
with ()
and ReasonReact.
with React.
, the only thing that the script does is adding quite a big compatibility layer at the end of the file.
let make =
ReasonReactCompat.wrapReasonReactForReact(
~component,
(
reactProps: {
.
"initialValue": option('initialValue),
"children": 'children,
},
) =>
make(~initialValue=?reactProps##initialValue, reactProps##children)
);
[@bs.obj]
external makeProps:
(~children: 'children, ~initialValue: 'initialValue=?, unit) =>
{
.
"initialValue": option('initialValue),
"children": 'children,
} =
"";
The snippet allows using old-fashioned components in JSX 3 syntax.
<Counter initialValue=32>
{num =>
" The world will end in "
++ string_of_int(num)
++ " years "
|> React.string}
</Counter>
You may notice that three little dots before curly braces surrounding children function are missing. In JSX 3 everything is single React.element
, there are no arrays anymore, so we don't need spread. This is the only visible change on the call site.
We're still far from having new JSX syntax in our component. Why to even bother with the script, then? It's useless in such small examples like this one, indeed, but in larger codebases, it may be the only way to start thinking about migration. The compatibility layer makes possible to rewrite components one by one while keeping the project compiling during the process. There is nothing worse than a huge refactor which turns everything upside down and blocks the development of business features and bugfixes for long days or weeks.
Moreover, this technique allows using in the new syntax external dependencies that expose JSX 2 interface only. Everything you have to do is to create a local module that wraps the external one with a snippet similar to one created by the migration script. Then, change all references to the original component, and voilá! You've made the library compatible with JSX 3. You can even make the pull request to the project repository, I bet maintainers will be more than grateful for such a contribution.
Going further
We're just in half of our journey. Let's make our hands dirty and write some code! To make the Counter
component closer to the JSX 3 syntax, first of all, remove component
record and use the new @react.component
decorator instead. Then, make children
a labeled argument by adding a little ~
to its name (notice, that's the way this parameter is declared in makeProps
function of compatibility layer). Finally, remove spread (...
) from the place where progress
name is used and, additionally, pass it to React.array
function. If you inspect the function, you’ll see that it creates single React.element
from an array of these. That's why we don't need a spread anymore.
[@react.component]
let make = (~initialValue=0, ~children) => {
let progress = Array.make(self.state, React.string("."));
<>
<button type_="button" onClick=(_ => self.send(Dec))>
("-" |> React.string)
</button>
(children(self.state))
<button type_="button" onClick=(_ => self.send(Inc))>
("+" |> React.string)
</button>
<pre> (progress |> React.array) </pre>
</>;
};
The code doesn't compile yet because of the self
object is no more defined. JSX 3 components are just pure functions, there is no context shared between renders. In these circumstances, where to store the state, how to tie reducer to the component? The answer is...
Hooks
The newest ReasonReact syntax is almost equal to the JavaScript counterpart. With the last update, we've got not only JSX 3 but also hooks like useState
, useEffect
and useReducer
. The latter is the one that we may use to accomplish the same effect as with reducerComponent
. There are two changes necessary to the reducer function itself: inverting arguments order (state
first, action
last), and removing ReasonReact.Update
wrapper from the returned value. In opposite to the reducerComponent
, hooked reducer always returns the next state. If you need to perform side effects, the useEffect
hook is here to serve you.
type action =
| Dec
| Inc;
let reducer = (state, action) =>
switch (action) {
| Dec => state - 1
| Inc => state + 1
};
[@react.component]
let make = (~initialValue=0, children) => {
let (state, send) = React.useReducer(reducer, initialValue);
let progress = Array.make(state, React.string("."));
<>
<button type_="button" onClick=(_ => send(Dec))>
And... that's it! Now we can just remove the compatibility layer added by the migration script and enjoy the component written with JSX 3 and hooks! The benefits are far less of boilerplate code, the consistent syntax for children (no more dots!) and architecture more similar to one known from JavaScript.
Final words
Migration from JSX 2 to JSX 3 doesn't need to be painful. Thanks to the script prepared by the ReasonReact team, it's quite easy to accomplish even in large codebases. Recently I did it in webpack-stats-explorer
, the tool for analyzing Webpack bundle stats and comparing them between builds. It's rather a medium-size project but it took me a few evenings, spent mostly on wandering around and wondering what should I do next. In this article, I've compiled knowledge from different documentation pages and community forums, trying to produce clear and transparent migration guide. I hope your experience will be much better thanks to this. Good luck!
Sources
- JSX 3 and Hooks announcment
- Hooks & co. docs
- JSX 3 docs
- Migration scripts repository
- Migration guide for ReasonReact Native (useful for web developers as well)
- Alternative migration script (worth to check, it may work with your project!)
Posted on October 28, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.