/rails-react-playground

An example repo setup for serving lazy-loaded React components through Rails using pnpm workspaces, Vite, and custom elements. Read more here: https://hipsterbrown.com/musings/musing/islands-on-the-rails/

Primary LanguageRuby

React in Rails Playground

An example repo setup for serving React components through Rails using pnpm workspaces, Vite Ruby, and Catalyst.

Overview

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.

Dev Workflow

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') %>