Hyperscript: hyperior alternative to JSX

Published on in JavaScript, Mithril.js and React

Last updated on

JSX is just JS under the hood. Hyperscript is like that JS, but better. Example: h('a.link', { href }, 'Click me') is nicer than <a className="link" href={href}>Click me</a>, right? Right?!

Table of contents

How JSX works

JSX is not valid JS – it's a syntax extension to JS. Let's consider the following imaginary React component written using JSX:

function Header({ links }) {
  return (
    <header id="my-header" className="header">
      <div className="logo">
        <a href="/">mtsknn.fi</a>
      </div>
      <nav>
        <ul className="header__links">
          {links.map(({ href, text }) => (
            <li key={href}>
              <a href={href}>{text}</a>
            </li>
          ))}
          <li>
            <DarkModeToggle />
          </li>
        </ul>
      </nav>
    </header>
  )
}

The code gets compiled by Babel before it's served to the browser[1], so that JSX gets replaced with React.createElement() function invocations[2]:

function Header({ links }) {
  return (
    React.createElement('header', { id: 'my-header', className: 'header' },
      React.createElement('div', { className: 'logo' },
        React.createElement('a', { href: '/' }, 'mtsknn.fi')
      ),
      React.createElement('nav', null,
        React.createElement('ul', { className: 'header__links' },
          links.map(({ href, text }) => (
            React.createElement('li', { key: href },
              React.createElement('a', { href: href }, text)
            )
          )),
          React.createElement('li', null,
            React.createElement(DarkModeToggle, null)
          )
        )
      )
    )
  )
}

Uh, that's a bit annoying to read. Let's make that a bit more readable by assigning React.createElement() to a new variable h. ("h" as in hyperscript – though note that this is not yet proper hyperscript.) Let's also simplify { href: href } into { href } because OCD:

import { createElement as h } from 'react'

function Header({ links }) {
  return (
    h('header', { id: 'my-header', className: 'header' },
      h('div', { className: 'logo' },
        h('a', { href: '/' }, 'mtsknn.fi')
      ),
      h('nav', null,
        h('ul', { className: 'header__links' },
          links.map(({ href, text }) => (
            h('li', { key: href },
              h('a', { href }, text)
            )
          )),
          h('li', null,
            h(DarkModeToggle, null)
          )
        )
      )
    )
  )
}

Now we can more clearly see that

  • the first argument of React.createElement() is the tag name or component;
  • the second argument is the props object (or null if the element doesn't get any props);
  • and the rest of the arguments are the children of the element.

So, under the hood, JSX is just regular JS. And it's not specific to React. Babel could as well replace JSX with something else than React.createElement() function invocations.

What hyperscript is and looks like

I couldn't find a proper definition for hyperscript, but think of it as the JS equivalent of JSX. You can use hyperscript to declare the structure of your (React) components in plain JS. Hyperscript is like the code in the previous code block, but better.

Let's take a look at two real-world hyperscript implementations to see what they look like.

Example 1: React with react-hyperscript

Looking at our previous code example, it would be nice if we could at least:

  • omit the second argument if it's null. This would make the code easier to read by reducing visual noise.
  • optionally wrap the children in an array. This would make indentation more logical and Prettier-compatible (is that a word?). It would also allow you to use trailing commas if that's your thing.

The react-hyperscript library supports that. It also supports:

  • using CSS selectors in the tag names to specify CSS classes and IDs. For example, h('header#my-id.foo.bar') is the same as h('header', { id: 'my-id', className: 'foo bar' }).
  • omitting the tag name if it's div, i.e. the default tag name is div. For example, h('.logo') is the same as h('div.logo').

Let's see what our component looks like when using react-hyperscript:

import h from 'react-hyperscript'

function Header({ links }) {
  return (
    h('header#my-header.header', [
      h('.logo', [
        h('a', { href: '/' }, 'mtsknn.fi'),
      ]),
      h('nav', [
        h('ul.header__links', [
          links.map(({ href, text }) => (
            h('li', { key: href }, [
              h('a', { href }, text),
            ])
          )),
          h('li', [
            h(DarkModeToggle),
          ]),
        ]),
      ]),
    ])
  )
}

That looks pretty good! We got rid of null props and some other prop objects as well by using CSS syntax. Children that are not on the same line as their parent are wrapped in arrays. This makes the indentation clearer.

(Side note: at the end of the code example, the DarkModeToggle component needs to be in an array because it's a component instead of a string. There's an open issue on GitHub about that. Unfortunately the library hasn't received any updates in a couple of years, but this issue is not a biggie.)

To start using react-hyperscript,

  1. run npm install react-hyperscript (or npm i react-hyperscript for short)
  2. add import h from 'react-hyperscript' to your code
  3. start replacing that filthy JSX with the h() function.

Example 2: Mithril

Mithril is a small (<10kb gzip) yet mighty JS framework. I prefer using Mithril over React in my own projects because it's so good. (I should write a blog post about it.)

Anyway, Mithril uses hyperscript by default. Mithril's hyperscript flavor has two main extra features compared to react-hyperscript:

  • Besides CSS classes and IDs, you can also specify static attributes in the tag name (first parameter) using CSS syntax. For example, m('a[href=/foo]') is the same as m('a', { href: '/foo' }). (Notice how Mithril uses m() instead of h(), but that doesn't matter in practice.)
  • It's more flexible about its input parameters – children can be in an array, but they don't have to. In our header component example, the DarkModeToggle component doesn't need to be in an array (unlike when using react-hyperscript).

Our component would look like this when using Mithril:

import m from 'mithril'

function Header({ links }) {
  return (
    m('header#my-header.header', [
      m('.logo', [
        m('a[href=/]', 'mtsknn.fi'),
      ]),
      m('nav', [
        m('ul.header__links', [
          links.map(({ href, text }) => (
            m('li', { key: href }, [
              m('a', { href }, text),
            ])
          )),
          m('li', m(DarkModeToggle)),
        ]),
      ]),
    ])
  )
}

That's almost identical than when using react-hyperscript, only a bit more terse. (I have highlighted the two lines that are different.) But being able to specify static attributes using CSS syntax is nifty, especially when you have more of them; the example header component has just one. And the increased flexibility regarding the input parameters means one thing less to worry about, i.e. whether children need to be wrapped in arrays or not.

Since Mithril uses hyperscript by default, there's no steps to enable hyperscript. Read the Mithril tutorial and start using it.

Pros and cons of hyperscript

Okay, that's nice and all, but why would you use hyperscript over JSX?

Pros

Here's a non-exhaustive list of the good sides of hyperscript:

  • No need for context switching between JS and XML because hyperscript is just JavaScript.
    • Hyperscript could also make it easier to understand how your JS framework of choice (be it React or Mithril or something else) works. When learning React and JSX, a possibly common frustration is "why can't I use if statements and for loops in my JSX?" When using hyperscript, it's probably clearer why you can't have if statements and for loops as function arguments.
  • CSS classes and IDs can be specified in the tag name which leads to terser code. Very nice when using e.g. Tailwind: h('img.h-16.md:h-24') etc.
    • In some flavors of hyperscript (e.g. Mithril), other CSS-like shorthands are also possible, like m('a[href=/foo].bar.baz').
  • Hyperscript is less verbose (CSS-like tag names, no closing tags). This leads to shorter code – without sacrificing readability if you ask me.
  • No need for JSX compilation. This leads to simpler tooling, simpler build processes and fewer dependencies.
    • You can start prototyping without having to setup complex build processes et cetera. (Though in the case of React, using Create React App is also easy and quick. But still much more complex!)
    • Simpler tooling also means that your code editor doesn't have to support or have plugins for JSX parsing, highlighting etc. Just open notepad.exe and start hacking like any advanced programmer.

Cons

  • Hyperscript is not as common as JSX (at least in the React world). Unless you can convince your team to choose hyperscript, JSX is likely the easier choice. If you use JSX, it might also be easier to attract new developers to your team due to familiarity. Though learning hyperscript shouldn't be at all difficult.

  • The syntax might take some time to get used to. While hyperscript is just JavaScript, there are a lot of different kinds of brackets. But like said: learning hyperscript shouldn't be difficult.

  • Because there aren't closing tags like in JSX, you can't see as well where tags end. I haven't personally found this to be a problem though.

    • Android Studio solves this problem nicely for Flutter projects. It automatically shows code comments at the end of widgets (think of React components) like this:

      return new MyWidget(
        child: Container(
          color: Colors.pinkAccent,
          height: 200,
          width: 200,
          child: Center(
            child: Text(
              'Bacon ipsum',
              style: TextStyle(
                color: Colors.white,
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ), // Text
          ), // Center
        ), // Container
      ); // MyWidget
      
  • There's runtime overhead of parsing the CSS-like selectors. This is unlikely a major performance problem unless you have lots of complex selectors, but something to be aware of.

    • If you use Mithril, you can setup a Babel plugin called mopt to statically optimize m() function invocations. Look ma, no more runtime overhead!

Bottom line

Hmm, looks I listed as many pros as I listed cons. Didn't I say that hyperscript is hyperior?!

If you ask me, the pros greatly outweight the cons. Give my hyperscript over JSX any day, thank you.

In the end it's a matter of personal preference and what works for you and your team. Because this is a personal blog, I'm free to call hyperscript hyperior anyhow, so checkmate, atheists. ;-)

Further resources

Footnotes

  1. It's also possible to use Babel in the browser to compile code on the fly. That would be slow and in most cases just unnecessary.

  2. React 17 introduced a new JSX transform that makes the output look different. This doesn't change the idea behind hyperscript.