DEV Community

John Jackson
John Jackson

Posted on • Updated on

Binding external components with ARIA properties in ReScript-React

This article was originally written with ReasonML and ReasonReact. I updated it in May 2021 to use ReScript.

ARIA properties are one of the remaining unsolved problems in ReScript. In ReactJS, you can write a component like this: <svg aria-label="Greetings" />. ReScript, with its stricter syntax, does not allow - in names. Workarounds exist, but no one has found a canonical solution yet.

You can read the official ReScript docs explanation here. ReScript uses some tricks to map safe versions of names to their JavaScript equivalents, so ariaLabel compiles to aria-label. But this trick only applies to DOM elements created by ReScript-React. In other words, ariaLabel will not magically compile to aria-label every time it’s used as a prop.

The problem

If you have an external component and your instinct is to write a binding like this, it won’t work:

module MyIcon = {
  @module("some-icon-pack") @react.component
  external make: (~ariaLabel: string) => React.element = "MyIcon"

<MyIcon ariaLabel="This doesn't work." />
Enter fullscreen mode Exit fullscreen mode

When this compiles to JavaScript, ariaLabel won’t transform into its kebab-case equivalent, making it useless.

The solution

It took me much too long to figure this out. As far as I can tell, it’s the most straightforward solution with minimal hassle and runtime cost:

module MyIcon = {
  external make: React.component<{..}> = "MyIcon"
  let make = (~ariaLabel: string) =>
    React.createElement(make, {"aria-label": ariaLabel})

<MyIcon ariaLabel="It works!" />
Enter fullscreen mode Exit fullscreen mode

Here, the external component isn’t bound like a regular @react.component, thus making it incompatible with JSX. But then we create a shadow make function that maps the ariaLabel argument to the correct syntax. When you run the compiler, the output works just as you’d expect.


If you look at the compiled JavaScript, you’ll noticed that it isn’t zero-runtime. Our second make function still exists as a wrapper around the external component. You can avoid this by manually writing React.createElement(MyIcon.make, {}) throughout your project instead of the JSX <MyIcon />, but I’m skeptical that the effort would be worthwhile.

You may also notice that the typing for the external component isn’t safe in my example code. Js.t({..}) essentially means “this is an object with anything you want inside it.” The unsafe typing is fixed by the fact that I annotated the types for my shadow make function’s props. However, if you want to use the non-JSX React.createElement function throughout your project, then you should properly type the Js.t object in the external binding.

Final thoughts

I hope you find this technique useful for your own ReScript-React bindings. If there’s a better way of accomplishing this, I’m happy to learn about it and update this post.

Discussion (2)

yawaramin profile image
Yawar Amin

There's a neat little trick you can do to make a 'spread' component that just injects the given props objects into its child:

module Spread = {
  let make = (~props, ~children) =>
    ReasonReact.cloneElement(children, ~props, [||]);
<Spread props={"data-testid": "foo"}>
  <div />
johnridesabike profile image
John Jackson Author

This is a great trick! Thanks.