@ka2n

Technology and beer

ReactとRuby on Railsを連携させる時によくやる方法

既存のRails製プロダクトのフロントエンドを徐々にReactベースに置き換えていったり、一部のUIコンポーネントだけReactで動かしたりする際に使っているヘルパーをメモしておきます。

Rails側にView用のヘルパーを設置する。

# app/helpers/react_helper.rb
module ReactHelper
  # Reactコンポーネントを表示します
  # props: Propsに渡すJSONエンコード可能なデータ
  def render_component(component, props = {})
    raw render(partial: 'helpers/react/component', locals: {
                 component: component,
                 props: props
               })
  end
end
/ app/views/helpers/react/_component.html.slim
div.react-component[ 
    data-component=(component)
    data-props=(props.to_json)
    ]

次に、webpackerのエントリーポイント配下で以下のスクリプトを読み込ませる。

// app/javascript/react-bridge.tsx

import React from "react"
import ReactDOM from "react-dom"


const selector = ".react-component"

/** RailsのviewからReactComponentを探して描画 */
const mountComponents = () => {
  document.querySelectorAll<HTMLDivElement>(selector).forEach(async host => {
    try {
      const componentName = host.dataset["component"]
      const Component: React.ComponentType<any> = _componentStore.get(
        componentName
      )
      assert(
        Component,
        `bridge.react: component not defined with name: ${componentName}`
      )
      if (!Component) return

      let props: object | undefined = undefined
      try {
        const rawProps = host.dataset["props"]
        props = rawProps && JSON.parse(rawProps)
      } catch (e) {}

      let options: MountOption | undefined = undefined
      try {
        const rawOptions = host.dataset["options"]
        options = rawOptions && JSON.parse(rawOptions)
      } catch (e) {}

      render(
        <ComponentWrapper>
          <Component {...props} />
        </ComponentWrapper>,
        host
      )
    } catch (e) {
      assert(false, e)
    }
  })
}

/** ページ遷移時に古いコンポーネントをGC */
const cleanupComponents = () => {
  const nodes = document.querySelector(selector)
  nodes && ReactDOM.unmountComponentAtNode(nodes)
}

const ComponentWrapper: React.FC<{ noTheme?: boolean }> = props => {
  // コンテキスト等の挿入はここで
  return <>{props.children}</>
}

/** コンポーネントの読み込み機構を初期化して監視を開始 */
export const start = () => {
  document.addEventListener("turbolinks:load", mountComponents)
  document.addEventListener("turbolinks:visit", cleanupComponents)
}

/** コンポーネントをローダーに追加 */
export const registerComponent = (component: any, name?: string) => {
  name = name || component?.displayName || component?.constructor?.name
  _componentStore.set(name, component)
}

function assert(value: any, message?: string | Error): asserts value {
  if (!value) {
    if (process.env.NODE_ENV === "production") {
      if (message) {
        console.log(message)
      }
    } else {
      throw message || "assertion error"
    }
  }
}

const _componentStore = new Map<string, any>()

次にapplication.jsに以下を挿入

// app/javascript/packs/application.ts
require("../react-bridge").start()

コンポーネントをViewから呼び出せるように登録する。(application.js等で実行)

const SomeComponent = () => (<div>Hello</div>)

registerComponent(SomeComponent, 'SomeComponent');

RailsのViewから以下の様に呼び出す。

= render_component 'SomeComponent', { foo: 'bar' }

まとめ