How to use JSX without React

Did you know you can use JSX without React? If you don't know what JSX is, this except from DRAFT: JSX Specification located at https://github.com/facebook/jsx may help:
JSX is a XML-like syntax extension to ECMAScript without any defined semantics. It's NOT intended to be implemented by engines or browsers. It's NOT a proposal to incorporate JSX into the ECMAScript spec itself. It's intended to be used by various preprocessors (transpilers) to transform these tokens into standard ECMAScript.
JSX was made popular by React. Here's a simple example of what it looks like:
class ShoppingList extends React.Component
{
  render()
  {
    return (
      <div className="shopping-list">
        <h1>Shopping List for {this.props.name}</h1>
        <ul>
          <li>Instagram</li>
          <li>WhatsApp</li>
          <li>Oculus</li>
        </ul>
      </div>
    );
  }
}
What you should notice is that it appears like you're intermingling HTML with javascript (or esNext or Typescript or whatever). Some will argue with this and point out that the JSX syntax is just "syntactic sugar" for calls to javascript functions (like React.createElement for example). I'm not here to debate whether or not JSX is a good idea. Rather, I want to illustrate how it is possible to use JSX without React. Most people know that it's totally possibly to use React without JSX. But ask the reverse question (can you use JSX without React?), and you're typically met with puzzled looks and responses like "why would you want to do that?" and "that doesn't make any sense." A quick search using your favorite search engine turns up many a StackOverflow with folks indicating that you can't (or it doesn't make sense to try to) use JSX without React because the transpiled JSX boils down to React.createElement calls. Before we go any further, I should give credit to (and you should read) this post. You will learn something (I certainly did). That post comes with a link to a CodePen that shows how JSX can be used without React, by leveraging the Babel transpiler and specifying a pragma to tell the transpiler what function to inject for each node of the JSX code (hint, it doesn't have to be React.createElement).

Leveraging Typescript for JSX

I'm going to show you a different way to use JSX. Rather than Babel, I'm going to illustrate how you could do it with Typescript. I'll assume you're using VS Code for this, but you certainly don't have to. You do, however, need to have Typescript installed globally, and I will assume that you've got that part already taken care of. Fire up VS Code and then create a tsconfig.json file.
// tsconfig.json
{
    "compilerOptions":
    {
        "target": "es5",
        "jsx": "react",
        "jsxFactory": "hyperscript"
    }
}
The two options to pay attention to are "jsx":"react" and "jsxFactory":"hyperscript". As far as I know, for the jsx compiler option, you only have 3 options: preserve, react, or react-native. You don't want preserve as that would leave your JSX alone to be processed further down the road by something else. You don't really want react either, as we're not using React at all, but Typescript requires that you specify the jsx compiler option if you're also specifying the jsxFactory option, and since that one is critical for what we're doing, we're stuck with having to also include "jsx":"react" in tsconfig.json, but don't worry, we're still not using React at all; I promise! The option "jsxFactory":"hyperscript" is key. It tells Typescript to translate any JSX code it sees into function calls to the hyperscript function (which doesn't yet exist, but we'll write it shortly). It does not, by the way, have to be called "hyperscript". You can call your function anything you like. Choose "jsxFactory":"greatGooglyMoogly" for all I care, or "jsxFactory":"I_Love_The_Avett_Brothers". I'm going to stick with hyperscript for now.

A simple HTML file

Next, let's create an HTML file. This will be brain-dead simple. Just a plain vanilla HTML file that you can load in any browser locally.
<!-- index.html -->
<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <title>JSX without React</title>
    </head>

    <body>
        <h1>I love the <a href="http://theavettbrothers.com">Avett Brothers!</a></h1>

        <script src="./example.js"></script>
    </body>
</html>
Right now of course, it's not going to do anything interesting, in part because example.js. doesn't yet exist. But we'll get there.

Creating the Typescript file

Create a new file, example.ts, and put the following code (or something reasonably similar if you prefer to make the example somewhat "your own.")
// example.ts
class Example
{
    static main(): void
    {
        alert('The Avett Brothers are the greatest!');
    }
}

Example.main();
Nothing earth-shattering here. We simply create a class, Example with a single, static method, main() that alerts a message. Still no JSX but fear not, we're getting closer. We need Typescript to compile (or transpile) this .ts file down to plain javascript for us. If you're using VS Code, you can configure a Typescript task runner. There are short-cuts to do this:
  1. Alt + Shift + P
  2. Type: 'task runner' (without the quotes)
  3. Choose 'Tasks: Configure Task Runner'
  4. Choose 'Typescript - tsconfig.json Compiles a Typescript project'
This will create a tasks.json file under a directory .vscode, which you could also do manually if you prefer. With that file in place, you should be able to press Ctrl + Shift + B to "build" or invoke the tsc compiler. Assuming it works, a new file should be generated, example.js, which means you can now load the index.html in your favorite browser, and (hopefully) see that it works.

What about JSX?

Ok, it's time. You want the ability to use JSX in your Typescript file, so let's (begin) to make this possible now. First, rename example.ts to example.tsx, so that Typescript knows you want it to parse TSX/JSX (JSX in a Typescript file is referred to as TSX). Next, let's write some TSX. Edit the example.tsx file and add the following:
let avettBros = (<div id="avettBros">
    <h1>The Amazing Avett Brothers</h1>
    <ul>
        <li>Scott</li>
        <li>Seth</li>
        <li>Bob</li>
        <li>Joe</li>
    </ul>
</div>);
What you will likely notice at this point are some red squiggly underlines on your HTML tags, indicating that something is wrong. Hover over one of them, and you'll see a message like in the following screenshot: "[ts] Cannot find name 'hyperscript'" [ts] Cannot find name 'hyperscript'. Hopefully this is an "ah ha" moment for you. Typescript recognizes what we're doing, and it's ready to parse the TSX and transpile it into function calls. Except that, we've told it to use the function hyperscript(...) instead of its default React.createElement(...), and that function doesn't (yet) exist. We can fix that. Add the following to your example.tsx file:
function hyperscript()
{
}
Build that and then take a look at the compiled example.js file. You should see something like this:
var avettBros = (hyperscript("div", { id: "avettBros" },
    hyperscript("h1", null, "The Amazing Avett Brothers"),
    hyperscript("ul", null,
        hyperscript("li", null, "Scott"),
        hyperscript("li", null, "Seth"),
        hyperscript("li", null, "Bob"),
        hyperscript("li", null, "Joe"))));
Our hyperscript function is being called for each node in our JSX code. Of course, our function does nothing at the moment. We need to fix that, but what should our hyperscript function do? Well, the ultimate goal is to find a way to render this JSX, and for that, we need DOM -- real DOM. But if you look for a moment and analyze what is being passed to the hyperscript function, you'll notice that it's a sufficient amount of information to build a virtual DOM. And with that, we can certainly turn it into real DOM, to be injected into the page. From a first-cursory look, it appears that what's being passed into our function is:
  • The name of an HTML tag, which will eventually become an element
  • A dictionary of key/value pairs representing that element's attributes
  • And one of more additional objects, passed as parameters, to be treated as the element's children
So using example.js's content as an illustration that would results in:
div with id 'avettBros'
 |-- h1 with text 'The Amazing Avett Brothers'
 |-- ul
 |---- li with text 'Scott'
 |---- li with text 'Seth'
 |---- li with text 'Bob'
 |---- li with text 'Joe'
The starting element is a DIV with id="avettBros" which has two children: an H1 (whose text content is "The Amazing Avett Brothers") and a UL. The UL of course, has additional children -- 4 of them: LI elements with text content representing the members of the band. Hopefully you're beginning to see how this could be turned into an object (a virtual DOM element, if you will) which can then be passed into a render(vdom) function (which we haven't written yet, but will soon) which creates an actual, real DOM, to be injected into the page.

The hyperscript function

Ok, so what should our hyperscript function do? What should it return? An object of course, but how? Well, we have pretty much everything we need, and a reasonable (but less-than-ideal first attempt might look like this):
function hyperscript(tagName, attributes)
{
   // gather all function params beyond the first two, into an array called children
   var children = Array.prototype.slice.call(arguments, 2);
   return { tagName: tagName, attributes: attributes, children: children }; 
}
I went old-school with that function for the benefit of anyone who might not yet be familiar with some of the newer, ESNext capabilities. We'll update it in a moment because (as you'll see shortly) some minor tweaks are needed anyway. In my example above, I've only used JSX to spit back some hard-coded HTML that I wrote. In reality, you'll probably be looping and building up this HTML rather than hard-coding it. For example:
class Example
{
    static bros:string[] = [ 'Scott', 'Seth', 'Bob', 'Joe' ];

    static listBros(): string[]
    {
        return Example.bros.map(bro => <li>{bro.toUpperCase()}</li>);
    }
}

let avettBros = (<div id="avettBros">
    <h1>The Amazing Avett Brothers<span>!</span></h1>
    <ul>
        <li>--start--</li>
        { Example.listBros() }
        <li>--end--</li>
    </ul>
</div>);
What you should notice here is that Example.listBros() doesn't actually return a virtual DOM object. It returns an array. An array of virtual DOM objects because bro => <li>{ bro.toUpperCase()</li> } is going to translate to hyperscript(..) calls. But it's still an array and not a virtual DOM object. This means that the children variable (the one defined in our hyperscript function as an array of all the function params beyond the first two) is going to be (in this case) a "mixed bag." In other words, some of the array items will be virtual DOM objects, others will be arrays themselves containing virtual DOM objects. But what we really want is a flattened array of virtual DOM objects. Here is what the children array of the virtual DOM object representing the UL will be as of right now:
// UL's children array
[ vdomObj, [ vdomObj, vdomObj, vdomObj, vdomObj ], vdomObj ]
See that array sandwiched between the first vdomObj (which would be <li>--start--</li>) and the last vdomObj (which would be <li>--end--</li>)? We want to flatten that into a non-nested array, something like:
// UL's children array
[ vdomObj, vdomObj, vdomObj, vdomObj, vdomObj, vdomObj ]
This is doable. We can flatten nested arrays (1 level nested arrays anyway) pretty easily. There's the old-school way, and the cool, new way. I'll start with old-school:
function hyperscript(tagName, attributes, children)
{
   var children = Array.prototype.slice.call(arguments, 2);
   children = Array.prototype.concat.apply([], children);
   return { tagName: tagName, attributes: attributes, children: children }; 
}
This is fine, but if you want to be like the cool kids, you'll use the new rest and spread elements. Rest is used to "gather up" into an array, the remaining elements in an expression, while Spread is used (often when calling a function but there are other applications as well) to "spread out" the elements of an array, as if you were passing the individual elements and not the array itself.
function hyperscript(tagName, attributes, ...children)
{
   children = [].concat(...children);
   return { tagName, attributes, children };
}
I sneaked a few other shortcuts in there as well. The return statement takes advantage of the ESNext features that let's you create objects whose properties are the same name as the variable. So in this case, it's the same as return { tagName: tagName, attributes: attributes, children: children }. I also used [].concat(...children) instead of Array.prototype.concat.apply([], children), because it's shorter, and, as previously explained, takes advantage of spread elements.

Rendering the Virtual DOM

Alright, it's time to render the virtual DOM we've created. For that, we need a render function. It'll have to be recursive. It could look like this.
function render(vdom)
{
    let dom:HTMLElement = document.createElement(vdom.tagName);
    for (let key of (vdom.attributes || {}))
    {
        dom.setAttribute(key, vdom.attributes[key]);
    }

    for (let child of vdom.children)
    {
        if (typeof child === 'string')
        {
            dom.appendChild(document.createTextNode(child));
        }
        else
        {
            dom.appendChild(render(child));
        }
    }
    return dom;
}
There are, of course ways to condense this code down and features of both esNext and Typescript that we could (should) be using here, but I've opted for mostly es5 style code, with just a little bit of Typescript thrown in. Hopefully this makes the code easier to understand for anyone who maybe hasn't yet caught up and started using tomorrow's javascript today. As far as recursive functions go, this one isn't too hard to understand. First, we create an element based on the tag name (vdom.tagName) we're given. Next, we add attributes to the element we created, based on the collection of given in vdom.attributes. Finally, we need to loop through vdom.children. If the child is just a plain 'ol string, life is easy. Just create a new text node and add it to our DOM element. Otherwise, we'll have to call render(child), in order to go through the rendering process on any sub-virtual DOM objects.

A full, working, example

Ok, here's the TL;DR. A full, working example. I know, I know. The TL;DR should come in the beginning. But I put a lot of work into writing this. I want people to read it, not just skip to "the good part." 😉
// example.tsx
function hyperscript(nodeName, attributes, ...children)
{
    children = [].concat(...children);
    return { nodeName, attributes, children };
}

function render(vdom)
{
    let dom:HTMLElement = document.createElement(vdom.nodeName);
    for (let key of (vdom.attributes || {}))
    {
        dom.setAttribute(key, vdom.attributes[key]);
    }

    for (let child of vdom.children)
    {
        if (typeof child === 'string')
        {
            dom.appendChild(document.createTextNode(child));
        }
        else
        {
            dom.appendChild(render(child));
        }
    }
    return dom;
}


class Example
{
    static bros:string[] = [ 'Scott', 'Seth', 'Bob', 'Joe' ];
     
    static listBros(): string[]
    {
        return Example.bros.map(bro => <li>{bro.toUpperCase()}</li>);
    }
}
     
let avettBros = (<div id="avettBros">
    <h1>The Amazing Avett Brothers<span>!</span></h1>
    <ul>
        <li>--start--</li>
        { Example.listBros() }
        <li>--end--</li>
    </ul>
</div>);

var dom = render(avettBros);
document.body.appendChild(dom);
Now, I have of course, just sloppily thrown my hyperscript and render functions directly into the global space. But it shouldn't be too hard to think about you you could improve the organization here with separate files and using imports and exports. I'm not doing that here; that can be an exercise for the reader. I should also point out that this example doesn't cover the scenario where you can create components that return JSX/TSX and use those in your JSX/TSX expressions.
class Avetts
{
   constructor()
   {
      return (<h1>My name is Stephen, and I love the Avett Brothers!</h1>);
   }
}

let myApp = (<div id="myApp">
   <p>Welcome to my app</p>
   <Avetts />
   <p>This is the end of my app</p>
</div>);
To be clear, the above is NOT something that will work with our code, as it is currently written. It's not too hard to modify the code to support this though. But this post is already quite long. I'll leave the code in this post as-is for now and save enhancing it for another post. Zip download of fully working example Using the link above, download and extract the zip file; then open the folder in VS Code. Press Ctrl + Shift + B to make the Typescript compiler run and it should generate an example.js file. Then open index.html in a browser and you should (hopefully) see everything working.

A Final Note

I want to point out here (though I'd hope this is obvious) that what I'm demonstrating in this post is for illustration purposes only. What I'm offering here is NOT production-ready code for you to take and use in your project. I'm NOT suggesting that you should do something like this build your own framework, and I'm certainly not suggesting that you should dump React and start doing this (the idea illustrated here doesn't even come close to replicating the features of React). If you take nothing else away from this post, at least understand this: this blog post is meant for learning and nothing more. Please don't copy/paste this code and then come yelling at me when you find it isn't robust enough, doesn't meet your needs or is full of bugs. You've been forewarned!

One Response

  1. Bill Kidwell Says:

    Great article. This really helps me see how JSX works.

    I created a codepen that lets you play around with this example.
    https://codepen.io/bill-kidwell/details/RLBmQe/

    I hope it is useful.

Got something to say?

Please note: Constructive criticism is welcome. Rude or vulgar comments however, are not and will be removed during moderation.