🙆‍♂️

Next.js App Router上でjQuery, Hotwire Stimulusを使ってみた

に公開

はじめに

Reactサーバコンポーネントはインタラクティブではありません。イベントを受け取って、JavaScriptで動きを加えることができません。

サーバコンポーネントはブラウザに送信されないため、useState のようなインタラクティブな API を使用できません。サーバコンポーネントにインタラクティビティを追加するには、"use client" ディレクティブを使用してクライアントコンポーネントと組み合わせます。

しかしブラウザまで行ってしまえば、Reactサーバコンポーネントも結局はHTMLです。したがってわざわざクライアントコンポーネントを使わなくても、jQueryHotwire Stimulusでインタラクティブにできます。

ここでは試しにNext.jsのApp Routerで作ったReactサーバコンポーネントのみのページ(クライアントコンポーネントは使用していません)をベースに、jQuery等でインタラクティブにするデモサイトを用意しました。

デモサイト

デモサイトはこちらにあります。

ソースコードはGitHubに用意しています

jQuery, Stimulusのコード

jQuery, StimulusのJavaScriptのコードはapp/layout.tsxにあります。

今回はアコーディオンの開閉をする単純なデモですが、アコーディオンのタイトルのDOM要素をaria-expanded=trueもしくはaria-expanded=falseにするだけのJavaScript。実際のアコーディオンの開閉はCSSで行います。

上半分がjQuery、下半分がStimulusのコードです。

Next.jsはこの<Script>タグを使用して、任意のJavaScriptを読み込んだり、書いたりできるようです。もちろんTypeScriptを別ファイルに記述して読み込むこともできます。

app/layout.tsx

<Script
  id="script-jquery"
  src="https://code.jquery.com/jquery-3.6.0.min.js"
  strategy="beforeInteractive"
/>
<Script
  id="script-application"
  strategy="afterInteractive">
  {`
    $(document).on("click", "[data-accordion='toggle']", function (event) {
      const toggle = event.currentTarget
  
      toggle.ariaExpanded = toggle.ariaExpanded === "true" ? "false" : "true";
    });
  `}
</Script>
<Script
  id="script-stimulus-application"
  strategy="afterInteractive"
  type="module"
>
  {`
  import { Application, Controller } from 'https://cdn.jsdelivr.net/npm/stimulus@3.2.2/+esm'
  window.Stimulus = Application.start()
  
  Stimulus.register('accordion', class extends Controller {
    static targets = ['togglable']
     
    toggle() {
      this.togglableTarget.ariaExpanded = this.togglableTarget.ariaExpanded === "true" ? "false" : "true";
    }
  })
  `}
</Script>

HTMLテンプレート(というかReactサーバコンポーネント)のコード

jQueryで操作されるHTMLテンプレート(Reactサーバコンポーネント)は下記のようになっています。(GitHub参照)

  • jQueryはdata-accordion="toggle"を目印に、マウスのクリックイベントに反応します。
  • aria-expanded="false"もしくはaria-expanded="true"への切り替えで、CSSを使ってアコーディオンの開閉をします。ここではTailwind CSSを使っているので、peer-aria-[expanded=false]で隣のHTML要素のaria-expanded="false"を読み取って、それに応じてアコーディオンの開閉をしています。詳しくはaccordion__content CSSクラスを定義しているCSSファイルglobals.cssを参照してください。

Stimulusのコードもよく似ています。

app/jquery/page.tsx

...

export default function Home() {
  return (
    <>
      <div className="max-w-xl mx-auto mt-16">
        ...
        
        <div className="mt-4 accordion">
          {/* Find the jQuery code in app/layout.tsx */}
          <div
            data-accordion="toggle"
            aria-expanded="false"
            className="group peer accordion__title"
          >
            <span>Title – This part is a Server Component.</span>
            <AccordionChevron/>
          </div>
          <div className="accordion__content">
            Accordion Content – This part is a Server Component.
          </div>
        </div>
      </div>
    </>
  );
}

app/globals.css

    .accordion__content {
        @apply peer-aria-[expanded=false]:hidden 
        text-gray-600 h-32 p-2 rounded-b-lg;
    }

クライアントコンポーネントのコード

jQuery, Hotwire Stimulusだけでなく、普通にクライアントコンポーネントを使った例も紹介します。

  • 簡単なアコーディオンだけですが、サーバコンポーネントとクライアントコンポーネントを一つのファイルにまとめることができませんので、クライアントコンポーネントは別ファイルに切っています。(AccordionToggle.tsx)
  • サーバコンポーネントの良さを最大限に活用するには、なるべくクライアントコンポーネントを少なくして、可能ならクラアンとコンポーネントの中身はサーバコンポーネントで書くのがベストプラクティスであると私は認識しています。
  • その他の<Accordion>, <AccordionChevron>, <AccordionContent>コンポーネントは全てサーバコンポーネントです (GitHub参照。実際、Tailwind CSSの長いクラスのリストを隠蔽するぐらいのことしかしていません。

app/client-components/page.tsx

export default function ClientComponents() {
  return (
    <>
      <div className="max-w-xl mx-auto mt-16">
        ...
        
        <Accordion>
          <AccordionToggle>
            <span>Title – This is a Client Component.</span>
            <AccordionChevron/>
          </AccordionToggle>
          <AccordionContent>
            Accordion Content – This part is a Server Component.
          </AccordionContent>
        </Accordion>
      </div>
    </>
  );
}

app/client-components/components/AccordionToggle.tsx

"use client"
import {ReactNode, useState} from "react"

export default function AccordionToggle({children}: {children: ReactNode}) {
  const [expanded, setExpanded] = useState(false)

  return (
    <div
      aria-expanded={expanded}
      onClick={() => setExpanded(!expanded)}
      className="group peer bg-green-300 text-green-700 cursor-pointer rounded-t-lg
            h-8 font-bold p-2 flex flex-row justify-between items-center
            aria-[expanded=false]:rounded-b-lg hover:bg-green-200"
      title="This is the only Client Component in this page."
    >
      {children}
    </div>
  )
}

最後に

いかがでしたでしょうか?実際に現場でNext.js Reactサーバコンポーネントの上にjQuery, Stimulusを書いてインタラクティブにすることはまずないと思いますが、原理的には可能であることが確認できたかと思います。

今回の例でもそうですが、結局のところReactサーバコンポーネントは、従来のHTMLテンプレートをレンダーして、別途JavaScriptを載せていくモデルに近いと私は個人的に考えていて、面白がっています。

Discussion