Inline JSX for React

Easily add React root components to your HTML templates using web component syntax.

Introduction

Back in the day, Laravel 6 came out-of-the-box with an app.js file that auto-wired up Vue components. After including app.js in your root Blade template, you could use Vue’s web component syntax anywhere to drop Vue components on your Blade templates, and they would resolve to the Vue component. Here’s the relevant snippet from Laravel 6 that made the magic happen:

/**
 * The following block of code may be used to automatically register your
 * Vue components. It will recursively scan this directory for the Vue
 * components and automatically register them with their "basename".
 *
 * Eg. ./components/ExampleComponent.vue -> <example-component></example-component>
 */

const files = require.context('./', true, /\.vue$/i)
files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default))

Using the two lines above, one could drop <example-component></example-component> anywhere you wanted on the page and achieve reactivity. This allowed you to get moving quickly without thinking too hard about marrying JavaScript to the server-rendered parts.

While Laravel has since abandoned the above in favor of Inertia or Livewire, the auto-resolution convention is a great fit for sprinkling reactivity on older applications. So when my company decided to pull React into our 10-plus year old application, I remembered my experience with Laravel 6 and created the proof-of-concept Inline JSX to suggest an improvement to our developer experience.

What does this solve?

Inline JSX is meant to provide a way to add React to an existing application. A full rewrite of the application into a single-page application is off the table. The application does not have a dedicated API, so creating new endpoints just to support React is beyond the scope of the React addition. The application has a capital “V” View layer that allows for providing server side data, which can be used to hydrate the React application with initial data.

If this sounds like an application you’re working on, Inline JSX may be for you!

Before Inline JSX

Adding React to an existing application is pretty straightforward—the React documentation covers it in the “Getting Started” section. It’s fine, but filled with boilerplate. It also leaves the question “how do we hydrate data?” unanswered… do we:

  1. Instantiate a global window object in the template and reference it after the React component mounts?
  2. Make a fetch call within the React component after the component mounts?

For this example, let’s assume a global window object. First, the template:

<html>
  <head>
    <script src="js/react.js"></script>
    <script src="js/react-dom.js"></script>
    <script src="js/add-to-cart.js"></script>
  </head>
  <body>
    <div id="add-to-cart"></div>
    <script>
      // instantiate a global `product` that can be used to hydrate the React component.
      // Alternatively, a `fetch` could be done in the React component itself.
      window.product = <?= json_encode($this->getProduct()); ?>;

      // Create the React component
      const addToCartRoot = ReactDOM.createRoot(
        document.getElementById("add-to-cart")
      );

      // Mount the component to the `#add-to-cart` DOM node
      addToCartRoot.render(<AddToCart />);
    </script>
  </body>
</html>

Then, in the component:

function AddToCart() {
    let product;

    // Instantiate the product on mount
    useState(() => {
        product = window.product
    }, [])
}

After Inline JSX

Using the inline JSX approach, things become a lot easier. After adding app.js to your page, all of React’s boilerplate is replaced with a <web-component></web-component> style DOM element. Instead of wondering if we should add a window global or fetch call, you add an HTML attribute containing any data needed to hydrate the React component after mount:

<html>
  <body>
    <!-- Add your React component using web component syntax -->
    <!-- Note: any props needed to hydrate the React component can be added as element attributes -->
    <add-to-cart product="<?= json_encode($this->getProduct()); ?>"></add-to-cart>

    <!-- `js/app.js` parses the DOM and converts React root elements into reactive components -->
    <script src="js/app.js"></script>
  </body>
</html>

The data passed to the component via HTML attributes can be destructured from the React component as you would normally expect:

// `product` is an HTML attribute in the above example
function AddToCart({ product }) {
    // ...
}

Caveats

This approach comes with some challenges. Some are limitations with the HTML parser, while others are architectural decisions that should be considered.

Case insensitivity

HTML tags and attribute names are case-insensitive. This means any PascalCase component names or camelCase prop names need to be converted to kebab-case equivalents.

The Inline JSX proof-of-concept accounts for this conversion, but to hammer the point home, here is how you can expect the HTML components to be converted to JSX:

<my-react-component html-attribute="hello world"></my-react-component>

Note the PascalCase for the React component, and the camelCase for the html attribute:

function MyReactComponent({ htmlAttribute }) {
    // ...
}

Self-closing tags

The HTML spec only allows a few specific elements to omit closing tags. For any other elements, if the closing tag is omitted, the HTML parser will think the tag was never closed. The browser will attempt to close the tag itself, and you may run into errors. For example:

<example-component />
<span>hello world</span>

The above may be parsed like the following:

<example-component>
    <span>hello world</span>
</example-component>

Communicating with other components

Since each component is technically a standalone React application, communication between different applications needs to be solved. Say, for example, you have a MiniCart component that needs to update once a user adds a product to the cart? In the inline-jsx repo, my solve for this was an event bus pattern:

const events = {}
let eventId = 0

const publish = (event, ...args) => {
  const callback = events[event]
  if (!callback) return console.warn(`${event} not found`)

  for (let id in callback) {
    callback[id](...args)
  }
}

const subscribe = (event, callback) => {
  if (!events[event]) {
    events[event] = {}
  }

  const id = eventId++
  events[event][id] = callback

  return {
    unsubscribe: () => {
      delete events[event][id]

      if (Object.keys(events[event]).length === 0) {
        delete events[event]
      }
    }
  }
}

export default { publish, subscribe }

The above is the entire event bus… but for something more full-featured, feel free to reach for Redux.

Once the event bus is added, you can publish events anywhere you want to communicate to an outside component:

import { v4 as uuid } from 'uuid'
import eventBus from '../eventBus'

function AddToCart({ product }) {
    const addToCart = (product) => (e) => {
        e.preventDefault()

        eventBus.publish('MINICART_ADD', {
            product,
            quantity: 1,
            message: {
                id: uuid(),
                type: 'success',
                title: `Product added to cart`,
                body: `${product.title} has been added to your cart.`,
            }
        })
    }

    return (
        <button onClick={addToCart(product)}>Add to Cart</button>
    )
}

Then, any components that need to act based on the published events just subscribe to the published event:

import eventBus from '../eventBus'

function MiniCart() {
    const [items, setItems] = useState([])

    useEffect(() => {
        const addItemsSubscription = eventBus.subscribe('MINICART_ADD', ({ product, quantity }) => {
            const item = { ...product, quantity }
            const oldItems = [...items]
            const itemIndex = oldItems.findIndex((i) => i?.id === product.id)

            if (itemIndex < 0) {
                setItems((i) => [...i, item])
            } else {
                const updatedItem = items[itemIndex]
                updatedItem.quantity++

                setItems((i) => [...items])
            }
        })

        return () => {
            addItemsSubscription.unsubscribe()
        }
    }, [items])

    // Return an actual minicart ...
    return <></>
}

Data grids

To be honest, there’s not really a solve for this using the inline JSX approach. While it’s easier to add simple React components to the page without having to introduce new API endpoints, hydrating a component that has the potential to have hundreds of thousands of rows begins to be a performance and memory concern from a server perspective. In this case, an API endpoint coupled with a fetch call would be a better approach.

Summary

Writing this proof-of-concept was fun—I got to work on something that I’m a big fan of improving, Developer Experience (DX), and I learned more about the some of the inner workings of React’s boot process. I hope people find it useful!

For a more thorough example of some use-cases of the inline JSX approach, check out the project’s index.html page: https://github.com/dstrunk/inline-jsx/blob/main/public/index.html