Top

Commentary

Multiple Updates (Shopping Cart)

複数箇所の更新(ショッピングカート)

Turbo Streamsは比較的特殊なケースで使う #

TurboにはTurbo Drive, Turbo Frames, Turbo Streamsがあり、適宜使い分けることは別途紹介しました。またTurbo DriveとTurbo Framesで大半のケースがカバー可能で、Turbo Streamsは比較的特殊なケースで使用するとも述べました。

ただし、Turbo Streamsの方が良いケースは間違いなくあります。またTurbo FramesよりもTurbo Streamsを好み人もいます。

人によって好みが異なる以上、単純にこれがTurbo Streamsの使いどき、あれがTurbo Framesの使いどきを紹介することはできません。そこでここではTurbo Streamsならではの特徴を紹介したいと思います。特徴を理解した上で、適宜選択していただければと思います。

ここではMPAからTurbo Streams、さらにReact側ではuseEffectからServer Actionsまでの様々なアプローチを紹介し、深く掘り下げたいと思います。

Turbo Streams固有の機能 #

まずはTurbo Streams固有の機能をまとめておきます。これがどのような意味を持つかは実際にTurbo Drive, Turbo Framesの動きを細かく見ていかないとわかりにくいと思いますので、その時に見直していただければと思います。ここで各機能の意義や使用場面を理解する必要はありません。

大雑把には、画面の部分置換が必要なケースの大半はTurbo Framesでカバーできますが、少し特殊な状況ではTurbo Streamsの柔軟性が必要になると考えるのが良いと思います。

Turbo Streams固有の機能
機能用途備考
Web Socketsに応答Pollingを使わずにリアルタイムで
サーバの情報を反映したい時
リアルタイムチャットなど
細かくて柔軟な更新置換だけでなく、
削除や追加などをしたい時
例えばtable全体を再レンダリングするのではなく、行を1つだけ追加や削除するときなど
レスポンスを柔軟に
指定できる
POSTに対して簡素にレスポンスしたい時
例えば「いいね」ボタンの実装など
Turbo Framesはformからの非GETのリクエストに対して、POST/Redirect/GETパターンを前提とする。そのため通信が2往復必要になる。Turbo Streamsはこれに縛られないので、レスポンス遅延などが理由でこれを避けたいときに使う
画面の複数箇所の更新画面上で離れた箇所を
効率的に更新したい時
Turbo Framesで画面の離れた複数箇所を同時更新する時は、全部を覆う大きいTurbo Frameを作る(Reactでいうstateのリフトアップに近い)。一方でTurbo Streamsなら個別に変更でき、効率的
サーバ側から
ブラウザを細かく制御
状況に応じてレスポンスを返したり、
refreshしたりを細かく制御したいとき
タグを追加しなくても使える<turbo-frame>を差し込めない時tableの中は使用できるタグがHTMLの規約上制限されており、<turbo-frame>が使いにくい

デモ内容:画面の複数箇所の更新 #

今回はショッピングカードの簡単なデモを用意しています。製品を選択してカートに追加すると、同時に2つのことが起こります。

  • "Add to Cart"ボタンが"Added to Cart"バッジに変更されます
  • 画面右上のカートアイコン(Cart Icon)で追加された品目数が更新されます

これをどう処理するかがTurbo Streamsとその他の技術の大きな違いになります。

作成する画面

Turbo Frames/Reactの考え方(ステートのリフトアップ) #

Reactにはステートのリフトアップというコンセプトがありますが、これはHotwireでも使います。

ReactのuseEffectバージョンでは"Add to Cart"ボタンとCart Iconは共通のcartステートによって同時に更新されます。製品を新規にカートに追加すれば"Add to Cart"ボタンは"Added to Cart!"バッジになり、Cart Iconでは製品数が1つ増加します。しかし"Add to Cart"ボタンとCart Iconは離れていますので、ステートのリフトアップを行います。つまり双方のコンポーネントを内包するより上位のコンポーネントにcartステートを持たせ、更新された時は上位コンポーネント以下を再レンダリングすることで、同時更新を実現します。

もちろん無駄が発生します。今回のケースでいうと、他の製品の"Add to Cart"ボタンは全く変更されないのに、再レンダリングされます。しかし通常は大きな負荷ではなく、またキャッシュなどの方法で最適化も可能なので特に気にしません。Turbo Framesの実装React useEffectの実装もこの考え方に沿っています。

なお、MPAやTurbo Drive, React Server Componentsはいずれも画面の部分更新はしません。<body>タグ以下はすべて再レンダリングされます。明示的にはリフトアップしていませんが、実質的にはステートがリフトアップする仕組みで動作します。

Turbo FramesやReact useEffectによる実装方法
Cart with Turbo Frames and React

MPA/Turbo Drive Morphingによる実装 #

MPAのデモおよびTurbo Drive Morphingのデモを用意しています。コードはGitHubに用意しています。MPAのGitHubおよびMorphingのGitHubをご確認ください。

コード #

templates/cart_mpa/index.ejs

<%- include("../layouts/header_no_js.ejs") -%>
<div class="my-10 px-4 sm:px-6 lg:px-8">
  <turbo-frame id="cart">
    ...
  </turbo-frame>
</div>
<%- include("../layouts/footer.ejs") -%>

templates/cart_morph/index.ejs

<%- include("../layouts/header_morph.ejs") -%>
<div class="my-10 px-4 sm:px-6 lg:px-8">
  <turbo-frame id="cart">
    ...
  </turbo-frame>
</div>
<%- include("../layouts/footer.ejs") -%>
  • メインのテンプレートですが、MPAの場合とTurbo Drive Morphingの場合の違いはheaderのところだけです。MPAで読み込んでいるlayouts/header_no_js.ejsはTurboを読み込みません。一方でTurbo Drive Morpingの場合はTurboを読み込んだ上、Morphingを支持するmetaタグがあります。

templates/layouts/header_morph.ejs

<head>
  ...
  <script src="/hotwire/javascript/turbo.es2017-esm.js" data-turbo-track="reload" type="module"></script>
  ...
  <meta name="turbo-refresh-method" content="morph">
  <meta name="turbo-refresh-scroll" content="preserve">
</head>
  • <meta name="turbo-refresh-method" content="morph">がMorphingを指示します。また<meta name="turbo-refresh-scroll" content="preserve">がスクロールの制御(この場合はリフレッシュ時にスクロール位置を維持すること)をおこなっています。

templates/cart_mpa/_product_add_button.ejs

<form method="post" action="/api/hotwire/cart_mpa/add_to_cart">
  <input type="hidden" name="product_id" value="<%= product.id %>">
  <button type="submit"
          data-turbo-submits-with="adding..."
          class="btn-primary border border-orange-600">Add to Cart</button>
</form>
  • MPAですので、データ更新はブラウザネイティブのformタグをそのまま使います。"Add to Cart"ボタンを囲むformタグで/api/hotwire/cart_frames/add_to_cartにPOSTリクエストを投げています(パラメータとしてproduct_idも送信)

pages/api/hotwire/cart_mpa/add_to_cart.ts

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {getCookie, setCookie} from "cookies-next"
import {Cart} from "@/repositories/cart";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  if (req.method !== "POST") { throw new Error("Bad request"); }

  const productId: string = req.body.product_id
  const cartString = getCookie("cart", {req, res})
  const cart: Cart = cartString ? JSON.parse(cartString) : {}

  cart[productId] = 1
  setCookie("cart", JSON.stringify(cart), {res, req})

  res.redirect(303, process.env.URL + "/api/hotwire/cart_mpa")
}
  • 先のPOSTリクエストは/api/hotwire/cart_mpa/add_to_cartのエンドポイントで受信されます。ここではproduct_idをcookieに保存します。そしてここがとても重要なのですが、元のページ/api/hotwire/cart_framesにStatus 303のredirectをします
  • Redirectすることの大きなメリットはコードが簡単になるということです。POST後の再描画のコードは全く不要で、元のページを単純に再利用できます
  • もう1つ大きなメリットはデータの二重送信の予防です。これは古くから POST/redirect/GETパターンと呼ばれ、ベストプラクティスとされてきました。端的に言うと、MPAにおいてはPOSTリクエストにStatus 200でレスポンスをしてしまうと、ブラウザのリフレッシュボタンを押した時に同じデータを二重送信してしまうリスクがありました。POST/redirect/GETパターンはこの可能性を減らしてくれます
  • SPAの場合はPOST/redirect/GETパターンが必須ではなくなります。ブラウザのリフレッシュをしてもPOSTリクエストが送信されないためです。ただしTurbo DriveはJavaScriptがないときにはMPAとして動作するように設計されていますし、見かけ上はMPAと変わりませんので、いくつか不具合が出ます。そのため、Turbo DriveではformをPOSTで送信した際のStatus 200 レスポンスは無視します。これはTurbo Framesも同じです。なおGETの場合やTurbo Streamsでレスポンスする場合はこの制限はありません

まとめ #

  • MPAを試していただくとわかるように、"Add to Cart"ボタンを押すたびに画面最上部にスクロールしてしまいます。MPAだとスクロール位置を含めた画面のステートがすべて消去され、再レンダリングのたびにゼロから画面を描き直しているためです。Turbo Drive Morphingの場合はSPAとなっていますので、スクロール位置を保持できます。そして<meta name="turbo-refresh-scroll" content="preserve">によって、以前のスクロール位置を保つように指示していますので、変化しません
  • コードは非常に簡単です。Morphingを使ってスクロール位置を維持する場合も、metaタグを追加するだけです
  • POST/redirect/GETパターンはネットワーク通信を2回行う必要があります。そのため、ネットワーク遅延が大きい場合はUI/UXが低下します

Turbo Framesによる実装 #

Turbo Framesはスクロール位置を保つもう一つの方法です。Turbo Framesの場合は上位コンポーネントとして、"Add to Cart"ボタンとCart Iconを囲む場所に<turbo-frame id="cart">タグを配置するだけです。スクロール位置は画面全体のステートなので、Turbo Frameを区切るとスクロール位置が変化しません。

それ以外は上述のMPAと変わりません。Turbo Frames版のデモはこちらコードはGitHubに用意しています。

コード #

templates/cart_frames/index.ejs

<%- include("../layouts/header.ejs") -%>
<div class="my-10 px-4 sm:px-6 lg:px-8">
  <turbo-frame id="cart">
    ...
  </turbo-frame>
</div>
<%- include("../layouts/footer.ejs") -%>
  • <turbo-frame id="cart">で囲むことによって、"Add to Cart"をクリックしたときの動作はturbo-frameの中だけに影響するようになります。今回のケースではボタンをクリックしてもスクロール位置が変わらないという効果が得られます
  • turbo-frameはデフォルトでは、それが囲む領域のすべてのformaタグを制御し、返ってきたレスポンスをturbo-frameの中に閉じ込める働きをします。つまり"Add to Cart"ボタンや"Clear Cart"ボタンはすべてturbo-frameタグの中だけを部分置換するように動作します。今回はこれで希望通りですが、そうしたくない場合はtargetdata-turbo-frame属性で変更できます

まとめ #

  • MPAに対して、turbo-frameタグを追加するだけです。簡単に実装できます
  • POST/redirect/GETパターンを使わないといけないので、"Add to Cart"を押してから2回ネットワーク通信が必要になります。ネットワーク遅延が2倍になり、ネットワークが遅い場合はUI/UXへのインパクトが大きくなります

Turbo Streamsによる実装 #

Turbo Streamsの場合はTurbo Frames, Reactの場合と大きく異なり、ステートのリフトアップは行いません。その代わり、Cart Iconおよび"Add to Cart"ボタンの要素を個別に更新します。

Turbo FramesおよびReactの場合は要素更新ロジックは別個に持たず、最初のレンダリングのロジックをそのまま使っていました。しかしTurbo Streamsの場合は要素更新ロジックを別途用意する必要があります

Turbo Streamsを使った実装のデモを用意していますのでご覧ください。またコードはGitHubに載せています

Turbo Streamsによる実装方法
Cart with Turbo Streams

コード #

pages/api/hotwire/cart_streams/add_to_cart.ts

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {render} from "@/helpers/template-renderer"
import {getCookie, setCookie} from "cookies-next"
import {Cart} from "@/repositories/cart";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<string>,
) {
  if (req.method !== "POST") { throw new Error("Bad request"); }

  const productId: string = req.body.product_id
  const cartString = getCookie("cart", {req, res})
  const cart: Cart = cartString ? JSON.parse(cartString) : {}

  cart[productId] = 1
  setCookie("cart", JSON.stringify(cart), {res, req})

  const resultText = render("cart_streams/add_to_cart.ejs",
    {productId, cart}
  )

  res.appendHeader("Content-Type", "text/vnd.turbo-stream.html")
    .status(200)
    .send(resultText)
}
  • MPAと比べて、Turbo Streamsを実装する際に変更するのは pages/api/hotwire/cart_streams/add_to_cart.tstemplates/cart_streams/add_to_cart.ejsです。つまりカートに製品を追加し、画面を更新するところです
  • 上記のpages/api/hotwire/cart_streams/add_to_cart.tsではproduct_idをcookieに保存後、cart_streams/add_to_cart.ejsテンプレートファイルにheader: Content-Type: text/vnd.turbo-stream.htmlをつけてブラウザに送信しています
  • MPA/Turbo Drive/Turbo Framesとの大きな差は、ここがStatus 303 redirectではなく、Status 200だということです

templates/cart_streams/add_to_cart.ejs

<turbo-stream action="update" target="<%= `product_${productId}` %>">
  <template>
    <%- include("_added_badge.ejs") %>
  </template>
</turbo-stream>

<turbo-stream action="update" target="cart_icon">
  <template>
    <%- include("_cart_icon.ejs", {cart}) %>
  </template>
</turbo-stream>
  • templates/cart_streams/add_to_cart.ejsは"Add to Cart"ボタンとCart Iconをそれぞれ別個に更新する指示です。上のturbo-streamが"Add to Cart"ボタンの箇所、下のturbo-streamがCart Iconの箇所になります
  • target=属性を使って、更新先の要素のidを指定します。
  • レスポンスのheaderのContent-Type: text/vnd.turbo-stream.htmlを見て、ブラウザはこれがTurbo Stream応答であることを知ります。そして上記の2つのturbo-streamを適宜処理して、"Add to Cart"ボタンとCart Iconを更新します

まとめ #

  • Turbo Framesに比べて更新箇所を細かく指定できます。余計な箇所を再レンダリングしません
  • これを実現するために、「何をどのように更新するか」を指示しています。<turbo-stream>タグを使って宣言的に記述します。記述はシンプルでわかりやすいものです
  • それでもTurbo Drive, Turbo FramesやReactの場合はそもそも更新ロジック記載が不要ですので、複雑になっていることは否定できません
  • 更新ロジックが単純な場合は問題ありませんが、これが複雑になると場合はTurbo FramesもしくはTurbo Drive Morphingを使って、更新ロジックそのものを不要にするべきケースが出てきます

複雑なUIを実現するためにTurbo StreamsとTurbo Framesで数多くの部分置換を行うと、開発の負担が大きくなります。TurboのMorphingを導入するきっかけはまさにこれです。

またHotwire以前のRuby on Railsで採用されていたSJR (Server-generated JavaScript Responses)はTurbo Streamsと発想が似ています。ただし<turbo-stream>タグを使った宣言的な記述ではなく、JavaScriptそのものをサーバからブラウザに送っていました。Turbo StreamsはSJRを簡略化して使いやすくしたものと言えます。

React useEffectによる実装 #

上述したようにReact useEffectによる実装とTurbo Framesによる実装は考え方が似ています。複数箇所を更新するためにステートをリフトアップする作戦です。ただしMPAをベースとしてシンプルな改変だけを行うTurbo Framesと比べて、ReactはCSRも使えますので色々な工夫ができます。それを紹介します。

React版は下記の構成を採用しました。

  • ショッピングカートUIでは製品情報(products)とカート情報(cart)の2種類のデータが必要です。Hotwireの場合は完全にSSRなので、サーバで2種のデータを統合します。一方でReactはCSR (Client Side Rendering)を行えますので、ブラウザの中でこの2つのデータを統合し、ブラウザでHTMLをレンダリングできます。今回はこの作戦を採用しました
  • サーバ側のSSR (Server Side Rendering)では製品情報(products)だけを使って製品リストをレンダリングします。これは初回画面ロードの高速化とSEOに有利にするためです
  • SSRのページがブラウザに表示され、Hydrationが完了したのちに、useEffectの中でサーバからカート情報(cart)を取得します。このデータを使ってCSR (Client Side Rendering)でカート情報を含めた製品リストを再レンダリングします
  • "Add to Cart"ボタンを押した時はカート情報(cart)のみをサーバから取得します。データ取得後、CSRで画面を再描画します

以下、コードを見ていきます(GitHubに掲載)。デモはこちらでご確認いただけます。

コード #

pages/cart/index.tsx

...

export async function getServerSideProps() {
  const response = await fetch(process.env.URL + "/api/products");
  const data = await response.json();

  return {props: {products: data}}
}

export default function CartPage({products}: { products: Product[] }) {
  const [cart, setCart] = useState<Cart | null>(null)

  useEffect(() => {
    fetch("/api/cart")
      .then(response => response.json())
      .then(data => setCart(data))
  }, [])

  function addToCart(productId: number) {
    fetch("/api/cart/add_to_cart", {
      method: "POST",
      headers: {
        "Accept": "application/json",
        "Content-Type": "application/json"
      },
      body: JSON.stringify({product_id: productId}),
    }).then(response => response.json())
      .then(data => setCart(data));
  }

  function clearCart() {
    fetch("/api/cart/reset", {
      method: "POST"
    }).then(response => response.json())
      .then(data => setCart(data))
  }

  return (
    <Layout>
      <div className="my-10 px-4 sm:px-6 lg:px-8">
        <div className="sm:flex sm:items-center">
           ...
            <div id="cart_icon">
              {cart
                ? <CartIcon cart={cart}/>
                : <span>Loading...</span>}
            </div>
           ...
        </div>
        <ProductList products={products} cart={cart} addToCart={addToCart} />
      </div>
    </Layout>
  )
}
  • products cartのステートはリフトアップされていますので、ステートはすべてCartPageコンポーネントが持っています。ステートはpropsでCartIcon, ProductListコンポーネントに渡されます
  • ステートを操作するイベントハンドラのaddToCart(), clearCart()もこのページにリフトアップされる必要があります。この関数もpropsでCartIcon, ProductListコンポーネントに渡されます
  • useEffectaddToCart()clearCart()はすべてカート情報だけをサーバから取得します。productsを再取得する必要がないためです
  • MPA/Turbo Drive/Turbo Frames/Turbo Streamsの場合はformタグを書くだけで、別途イベントハンドラを記載する必要がありませんでした。Reactの場合はイベントハンドラをカスタムで記述する必要があります

components/cart/ProductAddButton.tsx

export default function ProductAddButton ({product, addToCart}: {product: Product, addToCart: (numberId: number) => void}) {
  return (<button type="button"
                  onClick={() => addToCart(product.id)}
                  className="btn-primary border border-orange-600">Add to Cart
  </button>)
}
  • 上の"Add to Cart"ボタンのonClickイベントはaddToCart()ハンドラに繋げています
  • addToCart()ハンドラは、pages/cart/index.tsx, components/cart/ProductList.tsxのprops経由で渡されてきています

まとめ #

  • MPAを少しだけ改変したTurbo Framesの場合と比べると、特にイベントハンドラ周りが複雑になっています
  • POST/redirect/GETパターンを使わないので、ネットワーク通信が1回で済みます。その分、レスポンスが高速になり、特にネットワークが遅い場合はUI/UXが優れています
  • CSRの特徴を活かして、productscartの情報を統合したレンダリングをブラウザで行なっています。おかげで"Add to Cart"ボタンをクリックしたとき、サーバからはcart情報のみを取得すれば十分です。ネットワーク負荷、サーバ負荷が減らせます

React Server Actionsによる実装 #

最新のReactはform周りの機能が追加されていて、Server Actionと一緒に使います。これは上述した従来のReactのハンドリングと大きく異なります。

ここではServer Actionによるデータ更新と画面更新の方法を確認します。

Server Actionsのデモはこちらに用意しています。またコードはGitHubにあります。

大雑把な特徴は下記のようになります

  • products(製品情報)およびcart(カート情報)は、そのデータを必要とするコンポーネントでそれぞれ取得します。つまりcolocationです。従来のReactと異なり、ステートのリフトアップは行いません
  • 同様にcart(カート情報)を更新するイベントハンドラもリフトアップする必要がありません。イベントが発生するコンポーネントに記述しています
  • "Add to Cart"ボタンのonClickにイベントハンドラを記述せず、buttonformで囲み、formaction属性からServer Actionを呼びます

それでは実際にコードを見てみます

コード #

app/cart_app/page.tsx

async function clearCart() {
  "use server"

  deleteCookie("cart", {cookies})

  revalidatePath("/cart_app")
}

export default async function CartPage() {
  return (
    <div className="my-10 px-4 sm:px-6 lg:px-8">
      <div className="sm:flex sm:items-center">
        <div className="sm:flex-auto">
          <h1 className="demo-h1">複数箇所更新 (ショッピングカート)</h1>
          <CartTechNav selected="server_actions"/>
          <div className="flex justify-between">
            <h2 className="demo-h2">Products</h2>
            <div className="flex">
              <form action={clearCart}>
                <button type="submit"
                        className="p-1 mr-4 border rounded border-orange-600 text-orange-600">Clear
                  Cart
                </button>
              </form>
              <div id="cart_icon">
                <CartIcon />
              </div>
            </div>
          </div>
        </div>
      </div>
      <ProductList />
    </div>
  )
}
  • Next.js Pages Routerによる実装ではリフトアップのため、productscartのデータ取得、さらにイベントハンドラがPageコンポーネントに集中していました。しかしRSCではこれを分散できますので、PageコンポーネントにはclearCart()しか残っていません
  • clearCart()は"use server"宣言があり、Server Componentです。"Clear Cart"ボタンのformのactionとして呼び出されています
  • clearCart()ではcookieをクリアしたのち、キャッシュを入れ替えるためのrevalidatePath()を呼びます。この指示により/cart_appのデータはクリアされますが、/cart_appのページにいますので、ページが再描画され、データも再度読み込まれます

なお公式ドキュメントによると、cookies.deleteをすればrevalidatePath()を呼ばなくてもデータが再読み込みされると書かれています。ただしこの辺りは複雑なので、今回はあえてrevalidatePath()を明示的に呼び出しています。

app/cart_app/components/CartIcon.tsx

export async function getCart(): Promise<Cart> {
  const cartString = getCookie("cart", {cookies})
  const cart = cartString ? JSON.parse(cartString) : {}

  return cart
}

export default async function CartIcon() {
  const cart = await getCart()

  return (
    <div className="flex items-center">
      ...
      <div className="w-8 text-right text-2xl font-bold">
        {Object.keys(cart).length}
      </div>
    </div>
  )
}
  • CartIconコンポーネントでcartが必要なため、getCart()を呼び出しています。Colocationです

app/cart_app/components/ProductList.tsx

async function getProducts(): Promise<Product[]> {
  const response = await fetch(process.env.URL + "/api/products");
  const products = await response.json();

  return products
}

export default async function ProductList() {
  const products = await getProducts()
  const cart = await getCart()

  return (
    <div className="mt-8 flow-root">
      <div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
        <div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
          <table className="min-w-full divide-y divide-gray-300">
            ...
            <tbody className="divide-y divide-gray-200 bg-white">
            {
              products.map((product, i) => {
                return (<tr key={i} className="divide-x divide-gray-200">
                  ...
                  <td className="text-center whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pr-0">
                    {cart[product.id.toString()]
                        ? <AddedBadge/>
                        : <ProductAddButton product={product}/>}
                  </td>
                </tr>)
              })
            }
            </tbody>
          </table>
        </div>
      </div>
    </div>

  )
}
  • ProductListコンポーネントではproductscartも必要なため、getProducts()getCart()を呼び出しています。getCart()はCartIconコンポーネントでも呼び出されていましたので、二重に呼び出していますが、影響は小さいので気にしません(colocationを優先させています)

app/cart_app/components/ProductAddButton.tsx

async function addToCart(productId: number, formData: FormData) {
  "use server"

  const cartString = getCookie("cart", {cookies})
  const cart = cartString ? JSON.parse(cartString) : {}
  cart[productId] = 1

  setCookie("cart", JSON.stringify(cart), {cookies, httpOnly: true})

  revalidatePath("/cart_app")
}


export default function ProductAddButton({product}: { product: Product }) {
  return (<form action={addToCart.bind(null, product.id)}>
    <button type="submit"
            className="btn-primary border border-orange-600">Add to Cart
    </button>
  </form>)
}
  • "Add to Cart"ボタンはformで囲み、formにServer ActionのaddToCart()を結びつけています。なおproduct_id引数をaddToCart()に渡す方法として、公式ドキュメントに記載のbindメソッドを使う方法を使用していますbindを使った方法はServer Componentでも使用でき、JavaScriptをロードしていない場合も正常に動作します。
  • addToCart()関数は"use server"があるので、Server Actionになります。product_idの情報を引数のFormDataから取得し、cookieをセットしています
  • 最後にrevalidatePath("/cart_app")を呼び出し、古いデータを無効化します。現在のページが/cart_appなので、サーバに最新の/cart_appページをリクエストし、レスポンスを画面に表示します
  • revalidatePath()はキャッシュをクリアし、指定されたパス以下のルートを再レンダリングするもので、Server Actionでデータを変更したのちに呼び出します。POST/redirect/GETパターンと異なり、POSTリクエストに対してStatus 200のレスポンスで応答し、ネットワーク通信1往復で済ませることができます

まとめ #

  • 常に画面全体をサーバでレンダリングしている点でMPA/Turbo Driveに近いと言えます。Turbo Streamsのように変更がある箇所だけを指定して更新したり、Reactのようにcartだけ更新してCSRしたりなどの最適化はされていませんが、その代わりにシンプルなコードになっています
  • データ更新の時にPOST/redirect/GETではなく、POSTに対してStatus 200で応答し、ネットワーク通信1往復で済ませている点はTurbo Streamsに似ています。処理が少ない通信で完了し、ネットワーク環境が遅くても優れたUI/UXが保てます

ただしPOSTに対してStatus 200でレスポンスしていますので、JavaScriptが未読み込みの場合は二重リクエストを送信する恐れがあります(非同期通信している場合は問題ありませんが、MPA的に動作しているため)。

最後に #

複雑なUIはコードのメンテナンス性との戦いです。複雑なUIの実装そのものは難しくないのですが、メンテナンスを意識したシンプルなコードを維持するのが大変です。様々なアプローチがあり、今回も多くの事例を紹介させていただきました。他の技術と対比することでTurbo Streamsの特徴が見えたかなと思います。

メンテナンス性(コードのシンプルさ)とUI/UX(レスポンス速度)のトレードオフになっており、パフォーマンス優先ならTurbo Streams、メンテナンス性を優先するならTurbo Drive/Framesになるように感じています。