Stimulusの中でJSXを使うと幸せになれるか?
お勧めしないけど、試しにStimulusの中でJSXを使ってみた
作成したのは下記のようなものです。
- ボタンを押して、画面を切り替えるところはStimulus controllerの中で書いています
- コンテンツは2つのJSXで書かれたコンポーネントに記述されています。Stimulusはこの2つのJSXコンポーネントのどれを表示するかを判断し、適宜DOMに差し込んでいます

使用したのは下記の環境です
-
Viteを使っています
- Vite/esbuildはファイルの中のJSXを読み込んで、JavaScriptの関数に書き換えてくれます。設定で変更可能ですが、今回は
React.createElement()とReact.Fragment()の関数に書き換えます(詳しくは文末に記述しています。 - Viteがここまでやってくれているおかげで、あとはDOM操作関数の
createElement()やappendChild()に変換するだけです
- Vite/esbuildはファイルの中のJSXを読み込んで、JavaScriptの関数に書き換えてくれます。設定で変更可能ですが、今回は
Stimulus Controllerの中身(JSXを含む)
メインのStimulus Controllerは下記のようになっています。ご覧のようにJSXのファイルになっていて(拡張子が.jsx)、ファイルの中でJSXを書いています。
ボタンを押すとStimulus actionによりswitch(event)が呼ばれます。その時に同時に送信されるevent.params.toId paramsの内容を見て、<JsxPage/>という名前のコンポーネントか、それとも<TsxPage/>という名前のものを表示するかを切り替えています。書き方としてはReact公式サイトでも紹介されている三項演算子を使った条件付きレンダーのパターンを使っています。
// src/controllers/switcher_controller.jsx
import {React} from '../jsx-runtime.ts'
import {Controller} from "@hotwired/stimulus"
import TsxPage from "../components/pages/tsx_page.tsx"
import JsxPage from "../components/pages/jsx_page.jsx"
export default class extends Controller {
static targets = ["outlet"]
switch(event) {
const toId = event.params.toId
this.outletTarget.replaceChildren(<>{
toId === 'jsx-page'
? <JsxPage/>
: toId === 'tsx-page'
? <TsxPage/>
: null
}</>)
}
}
React関数からDOM操作関数に変換するコード
変換コードはごく一部を除き、下記で全てです。難しいところは全てVite/esbuildが既にやってくれていますので、とても簡単です。
なおここではDOM生成のみをやっていますので、React純正のJSX処理で行われるイベントハンドラー処理やvirtual DOM、差分検知などの処理はありません。
function jsxToElement(tag: Tag, props: Props, ...children: ChildNode[]) {
if (typeof tag === "function") return tag(props, children)
const element = tag === "jsx-fragment" ? document.createDocumentFragment() : document.createElement(tag);
if ("setAttribute" in element) {
Object.entries(props || {}).forEach(([name, value]) => {
const convertedAttributeName = convertAttributeCamelCase(convertAttributeClassName(name))
element.setAttribute(convertedAttributeName, value)
})
}
appendChild(element, children)
return element
}
function add(parent: ContainerNode, child: ChildNode) {
const childNode = typeof child === 'string' ? document.createTextNode(child) : child;
parent.appendChild(childNode);
}
function appendChild(parent: ContainerNode, child: ChildNode | ChildNode[]) {
if (Array.isArray(child)) {
child.forEach((nestedChild) => appendChild(parent, nestedChild));
} else {
add(parent, child);
}
}
StimulusとReactの書き方の違い
慣れていないせいか、非常に頭が混乱します。Stimulus/HotwireっぽいUIの書き方と、ReactっぽいUIの書き方を行ったり来たりして、何回もコードを書き直しました。最終的にはStimulusの箇所はStimulusっぽく、ReactのところはReactっぽく書けた気がしています。
- Stimulusっぽいのはcontrollers/radio_controller.jsのコードです。ここでは
this.checkableTargetのaria-checkedだけを切り替える操作をしています。表示切り替えは、aria-checkedを認識するCSSセレクタを使って行っています。具体的にはsrc/style.cssをご確認ください。 - Reactっぽいのはcontrollers/switcher_controller.jsxのコードです。Reactでは条件付きレンダーが多用され、DOM自体を大きく変化させることで表示を切り替えます
どっちが便利かということは今のところあまり感じていません。ただしStimulus/Hotwireに慣れてしまったら、一貫してそれで通した方が頭の切り替えをしないで済みそうです。
またHotwire/Stimulusの考え方のまま、DOMを大きく書き換える処理を行う場合はブラウザネイティブの<template>タグが便利だと思います。<template>の中のDOM要素をquerySelector()等で選択し、その都度内容を書き換える感じで使います。
補足
StimulusはJavaScriptでHTMLをレンダリングすることをお勧めしない
Hotwire/StimulusはReactと考え方が大きく異なり、馴染みにくいという意見もよく聞きます。話を聞いていると、HTMLをなるべくクライアントで書かず、サーバ側でレンダリングしておくという考え方に切り替えるのが難しいようです。
HTMLをなるべくブラウザ側で書かないことについては、Stimulusの公式サイトでも言及しています。
今回はStimulusからJSXをレンダリングする方法を紹介しましたが、これはStimulusの使い方としてはどちらかというと非推奨のもので、どうしてもという場合にだけ使うとご理解ください。
As you can see, Stimulus doesn’t bother itself with creating the HTML. Rather, it attaches itself to an existing HTML document. The HTML is, in the majority of cases, rendered on the server either on the page load (first hit or via Turbo) or via an Ajax request that changes the DOM.
Stimulus is concerned with manipulating this existing HTML document. Sometimes that means adding a CSS class that hides an element or animates it or highlights it. Sometimes it means rearranging elements in groupings. Sometimes it means manipulating the content of an element, like when we transform UTC times that can be cached into local times that can be displayed.
There are cases where you’d want Stimulus to create new DOM elements, and you’re definitely free to do that. We might even add some sugar to make it easier in the future. But it’s the minority use case. The focus is on manipulating, not creating elements.
ご覧のとおり、Stimulus は自分で HTML を生成することには関与しません。むしろ、既に存在している HTML ドキュメントに対して動作します。HTML は多くの場合、サーバー側でレンダリングされます。ページロード時(初回アクセスや Turbo 経由のアクセス)や、DOM を変更する Ajax リクエストなどによって描画されます。
Stimulus の関心は、この既存の HTML ドキュメントを操作することにあります。たとえば、要素を非表示にしたり、アニメーションをつけたり、ハイライトしたりするために CSS クラスを追加することがあります。また、要素の並び替えを行う場合もあります。あるいは、要素の内容を操作することもあり、たとえばキャッシュ可能な UTC 時刻をローカル時刻に変換して表示する場合などがそれにあたります。
Stimulus に新しい DOM 要素を生成させたい場合もあるでしょうし、その自由もあります。将来的には、それをより簡単にするための糖衣的な仕組みを追加するかもしれません。しかし、それはあくまで少数派のユースケースです。Stimulus の焦点は、要素を「生成すること」ではなく、「操作すること」にあります。
なおこれは私見になるのですが、ブラウザの中でJavaScriptを使ってHTMLをレンダリングする場合、表示項目のデータをサーバから送る必要があります。これが簡単にできる場合は良いのですが、認可や権限によって表示項目を変えなければならない場合、秘密にしておかなければならないデータがある場合、深い階層になっているデータの場合など、思いの他に大変な場合が少なくありません。結果として大変なJSON基礎工事をしているコードはよく見かけます。
一方でサーバでレンダリングされたHTMLをメインとすれば、JSON基礎工事は完全に不要になります。StimulusやHotwireの開発効率が抜群に良いのは、JSON基礎工事が不要だという点が大きく寄与しています。そしてこれはまさしくNext.js App Routerで採用されているReact Server Components (RSC)がやっていることです。
Reactのバラ売り
今回のケースに限らず、最近のトレンドとしてReactがバラ売りされているように感じます。Reactの強みを部分的に剥いだようなフレームワークや、Reactの強みをvanilla JSや別フレームワークから使えるようにしたライブラリが多いように感じています。
- Remix 3はReactからリアクティビティを剥がして、ステート更新が自動で再レンダリングを引き起こすのではなく、手動で再レンダリングする方法を採用しています
- HonoではJSXをHTML記述用のテンプレートとして使用し、レンダリングされたHTMLをサーバからブラウザに送ります
- React本流のRSC (React Server Components)およびNext.jsのApp routerを見ると、これはリアクティビティが全くない、ほぼHTMLだけと同じようなもの(RSC payload)をサーバから送信します。これを作るのがServer Componentsです。React本流自身が、リアクティビティがないserver componentと、これがあるclient componentをバラバラで売る感じになっていて、リアクティビティをバラ売りしています
- ZustandやJotaiなどは手軽に使用できるステート管理ライブラリで、Reactのステート管理を別パッケージにした感じがします
- Reactの特徴である単方向データフローや宣言的UIはStimulus controllerの書き方次第で実現できるように私は思っています
参考: Vite/esbuild処理後のJSX
Vite/esbuildで処理された後のJSXファイルの中身を紹介します。JSXの箇所がReact.createElement()関数とReact.Fragment()関数に置き換わっているのがわかります。
今回のjsx-runtime.tsで用意しているのは、React.createElement()関数とReact.Fragment()関数のエキスポートです。Reactが用意しているこれらの関数の代わりに、jsx-runtime.tsで定義したものに置き換えて、DOM要素を作らせています。なおReactという名前は残っていますが、Reactのライブラリは一切使用していません。
export function SwitcherButtons() {
return /* @__PURE__ */
React.createElement("div", {
role: "radiogroup",
className: "buttons_row",
"data-controller": "radio"
}, /* @__PURE__ */
React.createElement(
"div",
{
role: "radio",
className: "button button--primary-outline",
"data-action": "click->switcher#switch click->radio#select",
"data-radio-target": "checkable",
"data-switcher-toId-param": "jsx-page",
"aria-checked": "true"
},
"JSX Page"
), /* @__PURE__ */
React.createElement(
"div",
{
role: "radio",
className: "button button--primary-outline",
"data-action": "click->switcher#switch click->radio#select",
"data-radio-target": "checkable",
"data-switcher-toId-param": "tsx-page"
},
"TSX Page"
));
}
Discussion