An example repo setup for serving React components through Rails using pnpm workspaces, Vite Ruby, and Catalyst.
While the original motivation for this setup was to support a monorepo with multiple Rails apps and engines and sharing JS assets, like custom elements and React components, among them, this simplified example shows a nice workflow where the JS assets can be built in isolation with modern tooling and served through the dependent Rails app like any other package.
pnpm workspaces are used to connect the internal JS package with the Rails app without needing to publish the package to a remote registry. The @playground
directory is used to allow for multiple internal packages to be scoped under the packages
field in the root pnpm-workspace.yaml
, but it's not required. @playground/core
is scaffolded using create-vite-app
and a React template because it is quick and comes with standard tooling for building TypeScript + React packages. The Rails app (playground
) is generated without Webpacker, then uses the bundle exec vite install
command after adding vite_rails
to the Gemfile.
When building components before integrating into the Rails app, run pnpm storybook
in the @playground/core
directory to start the Storybook server. Learn more about Storybook and writing component stories. You'll find an example under the @playground/core/stories/
directory.
To serve a React component in the Rails app, a custom element powered by Catalyst (react-island.tsx
) is used to mount the component on demand. Any component file placed in the @playground/core/src/islands
directory will be automatically registered by the vite-plugin-react-islands
build plugin which is executed through the import 'virtual:react-islands'
in the src/index.ts
of the core internal package. This is similar to the conventions use by Fresh.
The plugin uses dynamic imports and the React.lazy API to split the bundled component and lazily load one when the custom element requests it.
To invoke the custom element in your Rails view:
<react-island data-name="thing" ></react-island>
Or the ViewComponent can be used instead:
<%= render ReactIslandComponent.new(name: "thing") %>
While iterating on the component in Rails, running foreman start -f Procfile.dev
within the Rails directory (playground
) and pnpm start
within the package workspace (@playground/code
) will auto-reload the page when the source files change.
If the React component should receive initial props from the Rails view, that can be done in two different ways:
<react-island data-name="thing" data-props="<%= {propName: 'some value'}.to_json %>"></react-island>
The hash could be an instance variable, it just needs to be stringified JSON data to be parsed by the custom element.
Or:
<%= render ReactIslandComponent.new(name: "thing", initial_props: {propName: 'some value'}) %>
The initial_props
argument for the ViewComponent will automatically stringify the hash for the rendered HTML output.
Because the react-island
island is lazily-defined, the loading behavior can be controlled through the data-load-on
attribute:
<react-island data-name="thing" data-load-on="visible"></react-island>
Or with the companion ViewComponent:
<%= render ReactIslandComponent.new(name: "thing", load_on: 'visible') %>