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
)
endThe 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="{ "name": "Angelos" }"
></div>Things to note:
"inside HTML attributes are escaped as"- 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:
components: anObjectmappingStringcomponent names to their respective imported component objectsrenderComponent: a function that takes an imported component object, its props and the target element to mount it toonBeforeMount: an optional callback to call after the DOM has loaded and before doing (mounting) anythingloadEventName: thedocumentevent 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
renderComponentfunction 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:
clientis accessible as a closure both byrenderComponentandonBeforeMount- You’ll want to change the
client’surlin 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'
});