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}) => {
  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('DOMContentLoaded', () => {
    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

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