Angelos Orfanakos

Render React components from Rails views

I recently needed a way to render React components from Rails controller views, without caring about SSR.

After searching a bit, I found webpacker-react but it seemed a bit unmaintained. I was also reluctanct to add a dependency for something that (in my mind) could be solved easily. In the end, I decided to go with a minimal, custom implementation.

A good practice when you want to implement something is to first think about the ideal API and work backwards. In my case this was the following helper call inside a Rails controller view:

<%= react_component('User', name: 'Angelos') %>

Let’s define the helper in app/helpers/application_helper.rb:

def react_component(component, props = {})
  content_tag(
    'div',
    nil,
    'data-react-component' => component,
    'data-react-props' => props.to_json
  )
end

The idea is simple: take a String component name and an optional props Hash and return a <div> element with the component name in a data-react-component attribute and its serialized props in a data-react-props attribute. Then let UJS handle the rest.

So the helper’s output for our ideal API example mentioned above would be:

<div
  data-react-component="User"
  data-react-props="{ &quot;name&quot;: &quot;Angelos&quot; }"
></div>

Things to note:

  • " inside HTML attributes are escaped as &quot;
  • We want to mount the rendered component to this <div> (its contents between <div> and </div>)

And that’s it for the Rails part.

We now need some JavaScript code that finds all elements with a data-react-component attribute (a technique called UJS), renders the component of each with its props, and mounts it to the target element.

Here it is in app/javascript/support/react_ujs.js:

export default ({
  components,  renderComponent,  onBeforeMount = () => null,  loadEventName = 'DOMContentLoaded'}) => {
  const mountComponentNode = (node) => {
    const componentAttr = node.dataset.reactComponent;
    const component = components[componentAttr];

    if (!component) {
      console.warn(`${componentAttr}: not registered`);
      return;
    }

    const propsAttr = node.dataset.reactProps;

    if (!propsAttr) {
      console.warn(`${componentAttr}: missing props`);
    };

    const props = propsAttr ? JSON.parse(propsAttr) : {};

    renderComponent(component, props, node);
  };

  const mountComponentNodes = (nodes) => {
    nodes.forEach(mountComponentNode);
  };

  const mountComponents = () => {
    const componentNodes = document.querySelectorAll('[data-react-component]');
    mountComponentNodes(componentNodes);
  };

  document.addEventListener(loadEventName, () => {
    onBeforeMount();
    mountComponents();
  });
};

A function is exported that takes an Object argument with the following options:

  1. components: an Object mapping String component names to their respective imported component objects
  2. renderComponent: a function that takes an imported component object, its props and the target element to mount it to
  3. onBeforeMount: an optional callback to call after the DOM has loaded and before doing (mounting) anything
  4. loadEventName: the document event name to listen to for mounting the components (defaults to 'DOMContentLoaded')

Finally, the remaining “glue” logic in app/javascript/packs/application.js:

import React from 'react';
import ReactDOM from 'react-dom';

import reactUJS from '../support/react_ujs';
import User from '../components/User';

const components = {
  User // equivalent to `User: User` in ES6
};

const renderComponent = (component, props, target) => {
  const element = React.createElement(component, props);
  ReactDOM.render(element, target);
};

reactUJS({
  components,
  renderComponent,
});

Things to note:

  • Lines 1, 2, 4: Import React, ReactDOM and react_ujs.js
  • Lines 5, 7-9: Import and assign components
  • Lines 11-14: Define a renderComponent function that renders a component to the DOM
  • Lines 16-19: Call reactUJS

A possible use of onBeforeMount is to set up some context before the component is rendered.

For example, to set up urql for performing GraphQL queries:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider, createClient} from 'urql';

import reactUJS from '../support/react_ujs';
import User from '../components/User';

let client;
const components = {
  User
};

const renderComponent = (component, props, target) => {
  const element = React.createElement(component, props);

  ReactDOM.render(
    <Provider value={client}>
      {element}
    </Provider>,
    target
  );
};

const onBeforeMount = () => {
  const csrfToken =
    document.querySelector('meta[name="csrf-token"]').getAttribute('content');

  client = createClient({
    url: 'http://localhost:3000/graphql',    fetchOptions: {
      headers: {
        'X-CSRF-Token': csrfToken
      }
    }
  });
};

reactUJS({
  components,
  renderComponent,
  onBeforeMount
});

Things to note:

  • client is accessible as a closure both by renderComponent and onBeforeMount
  • You’ll want to change the client’s url in production

Finally, if you have Turbolinks enabled, you’ll want to mount the components each time Turbolinks loads instead of on DOMContentLoaded. So the final call to reactUJS becomes:

reactUJS({
  components,
  renderComponent,
  loadEventName: 'turbolinks:load'
});