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="{ "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
: anObject
mappingString
component 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
: thedocument
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 byrenderComponent
andonBeforeMount
- You’ll want to change the
client
’surl
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'
});