Commentary
複数箇所の更新(ショッピングカート)
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 Drive, Turbo Framesの動きを細かく見ていかないとわかりにくいと思いますので、その時に見直していただければと思います。ここで各機能の意義や使用場面を理解する必要はありません。
大雑把には、画面の部分置換が必要なケースの大半はTurbo Framesでカバーできますが、少し特殊な状況では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つのことが起こります。
これをどう処理するかがTurbo Streamsとその他の技術の大きな違いになります。
作成する画面
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>
タグ以下はすべて再レンダリングされます。明示的にはリフトアップしていませんが、実質的にはステートがリフトアップする仕組みで動作します。
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") -%>
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>
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")
}
/api/hotwire/cart_mpa/add_to_cart
のエンドポイントで受信されます。ここではproduct_id
をcookieに保存します。そしてここがとても重要なのですが、元のページ/api/hotwire/cart_frames
にStatus 303のredirectをします。<meta name="turbo-refresh-scroll" content="preserve">
によって、以前のスクロール位置を保つように指示していますので、変化しませんmeta
タグを追加するだけです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
はデフォルトでは、それが囲む領域のすべてのform
とa
タグを制御し、返ってきたレスポンスをturbo-frame
の中に閉じ込める働きをします。つまり"Add to Cart"ボタンや"Clear Cart"ボタンはすべてturbo-frame
タグの中だけを部分置換するように動作します。今回はこれで希望通りですが、そうしたくない場合はtarget
やdata-turbo-frame
属性で変更できますturbo-frame
タグを追加するだけです。簡単に実装できますTurbo Streamsの場合はTurbo Frames, Reactの場合と大きく異なり、ステートのリフトアップは行いません。その代わり、Cart Iconおよび"Add to Cart"ボタンの要素を個別に更新します。
Turbo FramesおよびReactの場合は要素更新ロジックは別個に持たず、最初のレンダリングのロジックをそのまま使っていました。しかしTurbo Streamsの場合は要素更新ロジックを別途用意する必要があります。
Turbo Streamsを使った実装のデモを用意していますのでご覧ください。またコードはGitHubに載せています。
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)
}
pages/api/hotwire/cart_streams/add_to_cart.ts
とtemplates/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
をつけてブラウザに送信しています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
を指定します。Content-Type: text/vnd.turbo-stream.html
を見て、ブラウザはこれがTurbo Stream応答であることを知ります。そして上記の2つのturbo-stream
を適宜処理して、"Add to Cart"ボタンとCart Iconを更新します<turbo-stream>
タグを使って宣言的に記述します。記述はシンプルでわかりやすいものです複雑な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による実装とTurbo Framesによる実装は考え方が似ています。複数箇所を更新するためにステートをリフトアップする作戦です。ただしMPAをベースとしてシンプルな改変だけを行うTurbo Framesと比べて、ReactはCSRも使えますので色々な工夫ができます。それを紹介します。
React版は下記の構成を採用しました。
products
)とカート情報(cart
)の2種類のデータが必要です。Hotwireの場合は完全にSSRなので、サーバで2種のデータを統合します。一方でReactはCSR (Client Side Rendering)を行えますので、ブラウザの中でこの2つのデータを統合し、ブラウザでHTMLをレンダリングできます。今回はこの作戦を採用しましたproducts
)だけを使って製品リストをレンダリングします。これは初回画面ロードの高速化とSEOに有利にするためですuseEffect
の中でサーバからカート情報(cart
)を取得します。このデータを使ってCSR (Client Side Rendering)でカート情報を含めた製品リストを再レンダリングします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
コンポーネントに渡されますuseEffect
、addToCart()
、clearCart()
はすべてカート情報だけをサーバから取得します。products
を再取得する必要がないためです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>)
}
onClick
イベントはaddToCart()
ハンドラに繋げていますaddToCart()
ハンドラは、pages/cart/index.tsx
, components/cart/ProductList.tsx
のprops経由で渡されてきていますproducts
とcart
の情報を統合したレンダリングをブラウザで行なっています。おかげで"Add to Cart"ボタンをクリックしたとき、サーバからはcart
情報のみを取得すれば十分です。ネットワーク負荷、サーバ負荷が減らせます最新のReactはform
周りの機能が追加されていて、Server Actionと一緒に使います。これは上述した従来のReactのハンドリングと大きく異なります。
ここではServer Actionによるデータ更新と画面更新の方法を確認します。
Server Actionsのデモはこちらに用意しています。またコードはGitHubにあります。
大雑把な特徴は下記のようになります
products
(製品情報)およびcart
(カート情報)は、そのデータを必要とするコンポーネントでそれぞれ取得します。つまりcolocationです。従来のReactと異なり、ステートのリフトアップは行いませんcart
(カート情報)を更新するイベントハンドラもリフトアップする必要がありません。イベントが発生するコンポーネントに記述していますonClick
にイベントハンドラを記述せず、button
をform
で囲み、form
のaction
属性から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>
)
}
products
、cart
のデータ取得、さらにイベントハンドラが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>
)
}
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>
)
}
products
もcart
も必要なため、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>)
}
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往復で済ませることができますcart
だけ更新してCSRしたりなどの最適化はされていませんが、その代わりにシンプルなコードになっていますただしPOSTに対してStatus 200でレスポンスしていますので、JavaScriptが未読み込みの場合は二重リクエストを送信する恐れがあります(非同期通信している場合は問題ありませんが、MPA的に動作しているため)。
複雑なUIはコードのメンテナンス性との戦いです。複雑なUIの実装そのものは難しくないのですが、メンテナンスを意識したシンプルなコードを維持するのが大変です。様々なアプローチがあり、今回も多くの事例を紹介させていただきました。他の技術と対比することでTurbo Streamsの特徴が見えたかなと思います。
メンテナンス性(コードのシンプルさ)とUI/UX(レスポンス速度)のトレードオフになっており、パフォーマンス優先ならTurbo Streams、メンテナンス性を優先するならTurbo Drive/Framesになるように感じています。