Rails7とhtmでReactしてみる

## Rails7襲来

Rails7がリリースされたということで、久々にTypeScriptから戻ってみたわけですが、どうやらフロントエンドに大きな変更が加わったらしく、色々調べてみました。

Rails7のフロントエンドのデフォルトはimportmapsという仕組みになるらしく、それは中々良さげな仕組みなのですが、説明する記事は多いので、この記事では触れません。

私の気を引いたのはこの動画でした。
なんと、Rails+importmapsでReactできるというのです。

https://www.youtube.com/watch?v=k73LKxim6tw

[htm][htm-url]というトランスパイル不要な、JSXを文字列で書くパッケージを使うようです。

ぱっと見かなり気持ち悪いのですが、食わず嫌いもなんなので、一度触ってみることにしました。

## rails new

とりあえずごく普通の`rails new`。
Rubyのバージョンは3.0.3。

## 適当なrootページを用意

```bash
./bin/rails generate controller home index
```

```rb:routes.rb
Rails.application.routes.draw do
  root 'home#index'
end
```

## htmとreactの設定

ココからが本番ですね。
動画とは少し違うことをしますが、大差はありません。

importmapsの設定。

```rb:importmap.rb
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"

pin "htm", to: "https://cdn.esm.sh/v45/htm@3.1.0/es2021/htm.js"
pin "react", to: "https://cdn.esm.sh/v45/react@17.0.2/es2021/react.js"
pin "react-dom", to: "https://cdn.esm.sh/v45/react-dom@17.0.2/es2021/react-dom.js"
pin_all_from "app/javascript/components", under: "components"
```

下で用意するcomponents/index.jsを読み込む。

```js:application.js
import "@hotwired/turbo-rails";
import "controllers";
import "components";
```

マウント用のroot divを用意する。

```erb:app/views/home/index.html.erb
<div id="root"></div>
```

htmとreactを関連付ける。
rawという名前は、VSCodeのプラグインとの相性のためです。

```js:app/javascript/components/raw.js
import { createElement } from "react";
import htm from "htm";

export const raw = htm.bind(createElement);
```

ハローワールド。

```js:app/javascript/components/index.js
import { render } from "react-dom";
import { raw } from "./raw";

render(raw`<h1>hello world</h1>`, document.getElementById("root"));
```

この段階で画面表示はこうなってるはずです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188872/9afcafaf-3f76-d049-4de4-9bf766fbf055.png)

JSXが使えていますね。
……文字列ですが。
今のところ問題なし。

## カウンターコンポーネントを追加する

とりあえず、とてもシンプルなカウンターコンポーネントを追加してみます。
動画ではclass componentを使ってましたが、hooksでもいけるみたいです。

```js:app/javascript/components/Counter.js
import { useState } from "react";
import { raw } from "./raw";

export function Counter() {
  const [num, setNum] = useState(0);

  return raw`
    <div>
      <button onClick=${() => setNum(num + 1)}>+</button>
      ${num}
    </div>
  `;
}
```

ちょっと変数展開の書き方が変わるくらいですね。
rawに渡しているのは文字列なので、自然とこうなるわけですね。

```js:app/javascript/components/index.js
import { render } from "react-dom";
import { raw } from "./raw";
import { Counter } from "./Counter";

render(
  raw`
    <h1>hello world</h1>
    <${Counter} />
  `,
  document.getElementById("root")
);
```

`<${Counter} />`うう……。

なんとかいけました。
通常のJSXとの違いは、自作コンポーネントの呼び出し方法と中括弧の前に`$`をつけることくらいでしょうか。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188872/a7e4d810-4c8a-d252-ec5e-afd3485c39d0.png)

useStateもちゃんと動作していますね。

## VSCode拡張機能

[lit-html][lit-html-url]というVSCodeの拡張機能を使えば、JSXは普通に書けました。
（`lit-html.tags`オプションが効かなかったのが玉に瑕ですが）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188872/99d96f0d-f1da-af83-4e1f-eb8c6e815442.png)

シンタックスハイライトも効いています。

## Propsを試す

Propsを渡すために、特に意味のないコンポーネントを追加する。

```js:app/javascript/components/RenderNum.js
import { raw } from "./raw";

export function RenderNum({ num }) {
  return raw`
    <div>${num}</div>
  `;
}
```

```js:app/javascript/components/Counter.js
import { useState } from "react";
import { raw } from "./raw";
import { RenderNum } from "./RenderNum";

export function Counter() {
  const [num, setNum] = useState(0);

  return raw`
    <div>
      <button onClick=${() => setNum(num + 1)}>+</button>
      <${RenderNum} num=${num} />
    </div>
  `;
}
```

普通に渡せますね。

## 外部Reactパッケージを使ってみる

これが出来なければきついですね。

[react-countup]を試してみます。

```rb:config/importmap.rb
pin "application", preload: true
pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
pin_all_from "app/javascript/controllers", under: "controllers"

pin "htm", to: "https://cdn.esm.sh/v45/htm@3.1.0/es2021/htm.js"
pin "react", to: "https://cdn.esm.sh/v45/react@17.0.2/es2021/react.js"
pin "react-dom", to: "https://cdn.esm.sh/v45/react-dom@17.0.2/es2021/react-dom.js"
pin "react-countup", to: "https://cdn.esm.sh/v45/react-countup@6.1.0/es2021/react-countup.js"
pin_all_from "app/javascript/components", under: "components"
```

```js:app/javascript/components/index.js
import { render } from "react-dom";
import { raw } from "./raw";
import { Counter } from "./Counter";
import CountUp from "react-countup";

render(
  raw`
    <h1>hello world</h1>
    <${Counter} />
    <${CountUp} end={100} />
  `,
  document.getElementById("root")
);
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188872/d4aa8f72-8023-908d-2574-54d2744cb3af.png)

あ……。

`Invalid hook call. Hooks can only be called inside of the body of a function component.`らしいです。

やり方が違うのか、出来ないのか判断できず、とりあえず諦めました。
詳しくて優しい人募集です。

## 感想

やや癖はありますが、意外といけますね。

（まだ使ったことはありませんが）stimulusが辛ければこちらを選択するのもありかも？

特にもしも外部Reactパッケージが使えるのであれば、かなり魅力的ではないだろうか。

検証に使ったコードは[github][rails-htm-react-url]にあります。

[htm-url]: https://github.com/developit/htm
[lit-html-url]: https://marketplace.visualstudio.com/items?itemName=bierner.lit-html&ssr=false#overview
[react-spinners-url]: https://github.com/davidhu2000/react-spinners
[react-countup]: https://github.com/glennreyes/react-countup
[rails-htm-react-url]: https://github.com/sijiaoh/rails-htm-react