interophigher-order-componentsreasonbucklescriptreason-react

How to bind to and use a higher-order component in ReasonReact


Let's say I have a higher-order component, something like the following trivial definition, exported from the JavaScript module ./hoc.js:

export const withStrong =
  Component => props =>
    <strong> <Component ...props/> </strong>

Assuming I have some component called HelloMessage, what is the equivalent of this piece of JavaScript:

import { withStrong } from './hoc.js';

const HelloMessage = ...

const StrongMessage = withStrong(HelloMessage);

ReactDOM.render(
  <StrongMessage name="Joe" />,
  document.getElementById('react-app')
);

Solution

  • TL;DR:

    This should be the exact equivalent of the requested JavaScript snippet:

    [@bs.module ./hoc.js]
    external withStrong
      : React.component('props) => React.component('props)
      = "withStrong";
    
    module HelloMessage = ...
    
    module StrongMessage = {
      include HelloMessage;
      let make = withStrong(make);
    };
    
    ReactDOMRe.renderToElementWithId(
      <StrongMessage name="Joe" />,
      "react-app"
    );
    

    There'a also a runnable example on the Reason playground with a few adaptations made to work around not having a separate JavaScript file.

    Explanation follows:

    Binding

    withStrong is just a function. It happens to be a function that accepts and returns a react component, which is a bit mysterious, but they're really just values like any other. We can just bind it like an ordinary function.

    Even something as simple as this would work

    [@bs.module ./hoc.js]
    external withStrong : 'a => 'a = "withStrong";
    

    assuming you always make sure to pass in a component. But it wouldn't be particularly safe as you can pass it anything else too, so let's try to use the type system as it should be used, restricting it to only accept react components.

    The ReasonReact source code says components have the type component('props), so that's what we'll use.

    [@bs.module ./hoc.js]
    external withStrong
      : React.component('props) => React.component('props)
      = "withStrong";
    

    Using the 'props type variable in both the argument and return type means we constrain them to be the same. That is, the returned component will have exactly the same props as the one passed in, which is exactly what we want in this case.

    And that's really all there is to the binding itself. we can now use it like this:

    let strongMessage = withStrong(HelloMessage.make);
    

    Unfortunately this doesn't support JSX. To render strongMessage as is we'd have to write something like

    React.createElementVariadic(strongMessage, { "name": "Joe" }, [||]);
    

    Not great. So let's fix that.

    JSX

    <StrongMessage name="Joe" />
    

    transforms to

    React.createElementVariadic(
      StrongMessage.make,
      StrongMessage.makeProps(~name="Joe", ()),
      [||]
    );
    

    So we need a StrongMessage module with two functions, make and makeProps that conform to what's expected by React.createElementVariadic. make is just the component itself, so that's simple enough. makeProps is a function that acccepts the props as labeled arguments terminated by unit (since the props may be optional) and returns a js object. This also happens to be exactly what [@bs.obj] does, which isn't in any way coincidental.

    Putting this together then, we get:

    module StrongMessage = {
      let make = withStrong(HelloMessage.make);
    
      [@bs.obj]
      external makeProps
        : (~name: string, unit) => {. "name" string }
        = "";
    }
    

    And that's it! Yay!

    Addendum: Shortcuts

    Ok, so the makeProps function is a bit of an annoying mouthful. Fortunately in our case, where the props of the wrapped component is the same as the original, it's also unnecessary since StrongMessage.makeProps will be identical to HelloMessage.makeProps. Let's just steal that then! And now we have

    module StrongMessage = {
      let make = withStrong(HelloMessage.make);
      let makeProps = HelloMessage.makeProps;
    }
    

    But we can do even better! By using include HelloMessage we can drop makeProps entirely (thanks to @bloodyowl, via @idkjs, for this one).

    module StrongMessage = {
      include HelloMessage;
      let make = withStrong(make);
    }
    

    That's pretty nice, isn't it? This works because include HelloMessage will include all the exported definitions from HelloMessage such as makeProps, but also make and anything else. This is probably what you want when you wrap a component in this way, but beware that it imports and re-exports everything from the included module, in case that's not what you want.

    Usage

    Finally, once we have both the binding and JSX in order we can use it like this

    ReactDOMRe.renderToElementWithId(
      <StrongMessage name="Joe" />,
      "react-app"
    );