Custom elements in React using a custom JSX pragma

gugadev

gugadev

Posted on July 8, 2019

Custom elements in React using a custom JSX pragma

You can test a sample project here:

GitHub logo gugadev / react-ce-ubigeo

Example using a custom JSX pragma for enable better tooling for custom elements.

This project was bootstrapped with Create React App.

How to test

Just run the app and drop/choose the ubigeo.txt file that is inside public/ folder.

Background

A element, by definition and spec, cannot accept complex properties like objects or arrays. This is a problem when we want to use these kinds of properties in a React project.

For example, this code doesn't work:

const App = function() {
  const data = { a: true }
  return (
    <div className="my-app"&gt
      <my-comp data={data} /&gt
    </div&gt
  )
}
Enter fullscreen mode Exit fullscreen mode

Because in runtime, the data passed as attribute is converted to string using .toString(). For that reason, if you pass an object, you will ended up receiving an [object Object] (because { a: true }.toString()).

Another problem of using custom elements in JSX is respect to custom

Online demo here:

Hey, you can use web components in JSX code anyway.

Yeah, sure. However, there are certain use cases where you cannot use a web component following React guidelines, like passing complex properties such Objects and Arrays and binding custom events. So, what could we do as a workaround for these? Let's see.

Passing objects/arrays to custom elements

There are some options. The easiest way is use JSON.stringify to pass it as a attribute:

const App = () => {
  const data = [
    { x: 50, y: 25 },
    { x: 29, y: 47 }
  ]

  return (
    <h1>My awesome app</h1>
    <x-dataset data={JSON.stringify(data)} />
  )
}
Enter fullscreen mode Exit fullscreen mode

Another option is use a ref to pass the object/array as property instead attribute:

const App = () => {
  const ref = useRef()
  const data = [
    { x: 50, y: 25 },
    { x: 29, y: 47 }
  ]

  useEffect(() => {
    if (ref.current) {
      ref.current.data = data // set the property
    }
  })

  return (
    <h1>My awesome app</h1>
    <x-dataset ref={ref} />
  )
}
Enter fullscreen mode Exit fullscreen mode

Hmm, I prefer the second one. And you?

Binding custom events

This is a very common case when we deal with custom elements. When you need to attach a listener to a custom event, you need to use a ref and use addEventListener yourself.

const App = () => {
  const ref = useRef()
  const data = [
    { x: 50, y: 25 },
    { x: 29, y: 47 }
  ]

  const customEventHandler = function(e) {
    const [realTarget] = e.composedPath()
    const extra = e.detail
    // do something with them
  }

  useEffect(() => {
    if (ref.current) {
      ref.current.data = data // set the property
      ref.current.addEventListener('custom-event', customEventHandler)
    }
  })

  return (
    <h1>My awesome app</h1>
    <x-dataset ref={ref} />
  )
}
Enter fullscreen mode Exit fullscreen mode

Pretty simple, right? But, could we make it even easier? Yeah! using a custom JSX pragma.

Creating a custom JSX pragma

This is not a very simple way when we create the pragma, but, once that, you don't need to add aditional logic like example above. You will ended up using custom elements as any regular React component!

The following code is a fork of jsx-native-events that I've extended adapt it to my needs.

First of all, what is a JSX pragma?

JSX Pragma

Pragma is just the function that transform JSX syntax to JavaScript. The default pragma in React is React.createElement.

So, that you understand this, let's see we have the following sentence:

<button type="submit">
  Hello
</button>
Enter fullscreen mode Exit fullscreen mode

Is transformed to:

React.createElement(
  'button',
  { type: 'submit' },
  'Hello'
)
Enter fullscreen mode Exit fullscreen mode

That's why we need to import React event if we don't use it explicitly!

So, what if we can take control over this transform process? That's exactly a pragma let us. So, let's code it.

So, what we did here? First, we need to get the check if it's an custom element. If is, assign a ref callback. Inside this callback we need to handle the logic.

Once inside the ref callback, get all the custom events and the complex properties. For the first one, the event handler name must start with the prefix onEvent (necessary to not conflict with regular JSX events). For the properties, we are going to check if the type is an object (typeof).

/** Map custom events as objects (must have onEvent prefix) */
const events =
Object
  .entries(props)
  .filter(([k, v]) => k.match(eventPattern))
  .map(([k, v]) => ({ [k]: v }))
/** Get only the complex props (objects and arrays) */
const complexProps =
Object
  .entries(props)
  .filter(([k, v]) => typeof v === 'object')
  .map(([k, v]) => ({ [k]: v }))
Enter fullscreen mode Exit fullscreen mode

At this point, we have both, the custom event handlers and the complex properties. The next step is iterate the event handlers and the complex properties.

for (const event of events) {
  const [key, impl] = Object.entries(event)[0]
  const eventName = toKebabCase(
    key.replace('onEvent', '')
  ).replace('-', '')

  /** Add the listeners Map if not present */
  if (!element[listeners]) {
    element[listeners] = new Map()
  }
  /** If the listener hasn't be attached, attach it */
  if (!element[listeners].has(eventName)) {
    element.addEventListener(eventName, impl)
    /** Save a reference to avoid listening to the same value twice */
    element[listeners].set(eventName, impl)
    delete newProps[key]
  }
}
Enter fullscreen mode Exit fullscreen mode

For each event handler, we need to:

  • convert the camel case name to kebab case: Eg. onEventToggleAccordion to toggle-accordion.
  • Add the event handler to the listeners map to remove the listener later.
  • add the listener to the custom element.

For the properties is pretty similar and simple:

for (const prop of complexProps) {
  const [key, value] = Object.entries(prop)[0]
  delete newProps[key]
  element[key] = value // assign the complex prop as property instead attribute
}
Enter fullscreen mode Exit fullscreen mode

Finally, call the React.createElement function to create our element:

return React.createElement.apply(null, [type, newProps, ...children])
Enter fullscreen mode Exit fullscreen mode

And that's all. Now, just left use it.

Using the custom JSX pragma

There are two ways of using a custom pragma. The first is through the tsconfig.json:

{
  "compilerOptions": {
    "jsx": "react",
    "jsxFactory": "webComponents" // default is "React.createElement"
  }
}
Enter fullscreen mode Exit fullscreen mode

The second one is through a comment at top of the files:

/** @jsx webComponents */
Enter fullscreen mode Exit fullscreen mode

Any of these two options you use need to import our pragma:

import React from 'react'
import webComponents from '../lib/ce-pragma' // our pragma

// our component
Enter fullscreen mode Exit fullscreen mode

Now, you can use your custom elements as any regular React component:

/** @jsx webComponents */
import { SFC } from 'react'
import webComponents from '../lib/ce-pragma'

export default function MyScreen() {
  const handleCustomEvent = function (e) {

  }
  return (
    <div>
      <my-custom-component
        data={[ { a: true} ]}
        onMyCustomEvent={handleCustomEvent}
      />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Using a custom pragma sounds like a very situable solution for now. Maybe in a short-term future React have better custom elements support. All could be possible in the crazy and big JavaScript ecosystem.

💖 💪 🙅 🚩
gugadev
gugadev

Posted on July 8, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related