React をテンプレートエンジンの代わりに使う
フロントエンドの専門家がいないことを理由に、React を使わず、バックエンドのテンプレートエンジンで出力した HTML に jQuery で動きをつける。
最近の React[1] なら、テンプレートエンジンに近い発想でウェブ UI が書けるので、そんなメンテナンス性の低いことをする必要はありません。
テンプレートエンジンでウェブ UI が書ける人なら React でも書けるし、むしろメリットがある。
そう思ってもらうのが目的の記事です。[2]
作るサンプル
次のようなウェブ UI を作ってみます。
次の JSON API のレスポンスを表示するだけのシンプルなものです。
[
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
},
{
"userId": 1,
"id": 2,
"title": "quis ut nam facilis et officia qui",
"completed": false
},
...
]
React に慣れていないと意外と書きづらい
このウェブ UI は、シンプルな 1 枚のページですが、React に慣れていないと意外と書きづらいものです。
バックエンドのテンプレートエンジンにはない、フロントエンドでの 状態管理、つまり API のロード待ちが含まれるからです。
フロントエンドにおいて API コールはつねに非同期処理です。
一方、React のレンダリングは同期処理であって API コールの完了を待てないため、ロード待ちか否かのフラグ管理、つまり状態管理がどうしても必要なのです。
「取得したデータを HTML に組み立てるだけ」のテンプレートエンジンに比べて React が書きづらい気がするのは、この理由が大きいでしょう。
問題編 - React とテンプレートエンジンの比較
次の 2 点を具体的なコードで見ていきましょう。
- テンプレートエンジンが「取得したデータを HTML に組み立てるだけ」で済むこと。
- 一方 React には API ロード待ちの状態管理が必要であること。
サンプルコードは GitHub にあります。
テンプレートエンジンで書いてみる
Handlebars を使う
テンプレートエンジンとして、Node.js で扱いやすい Handlebars を使います。
テンプレートエンジンには、ほかにも Blade (PHP), eRuby (Ruby), Twirl (Scala) などがあります。
しかし、データを取得してから HTML を出力する流れは Handlebars も含めみな同じです。
バックエンドは、HTML 出力後に描画を変えられない、つまり最終系の HTML を出力するほかないからです。
ソースコード
ルーティング部分の実装は次のとおりです。
URL パス /
に対して app
関数の結果を返すようになっています。
読み込みの遅延をあえて入れています。
server.get("/", async (req, res) => {
await fakeDelay(1_000)
res.setHeader("Content-Type", "text/html")
res.end(await app(req.query))
})
app
関数は次のとおりです。
fetch()
で取得した API レスポンスを template()
に渡し、組み立てた HTML を返しています。
const template$ = fs
.readFile(path.resolve(__dirname, "./app.hbs"), "utf-8")
.then(Handlebars.compile)
export async function app(query: Query): Promise<string> {
const currentPage = parseInt(query.page as string) || 1
const todos = await fetch(
`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
).then((r) => r.json())
const template = await template$
return template({
pages: [1, 2, 3, 4, 5].map((page) => ({
page,
current: page === currentPage,
})),
todos,
})
}
テンプレートは次のとおりです。
変数の埋め込みや #each
, #if
による制御文を使っています。
{{#> layout}}
<div>
<h1>Handlebars Example</h1>
<div style="display: flex; gap: 8px">
{{#each pages}}
<a href="?page={{page}}" style="padding: 8px">
{{#if current}}
<b>
<u>{{page}}</u>
</b>
{{else}}
{{page}}
{{/if}}
</a>
{{/each}}
</div>
<table style="table-layout: auto">
<thead>
<tr>
<th>ID</th>
<th>TITLE</th>
<th>COMPLETED</th>
</tr>
</thead>
<tbody>
{{#each todos}}
<tr>
<td>{{id}}</td>
<td>{{title}}</td>
<td>
{{#if completed}} YES {{else}} NO {{/if}}
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
{{/layout}}
結果は次のとおりです。
API コールを待てている
この実装で注目すべきは、HTML を組み立て始めるまで API コールを待てている点です。
非同期処理を待てるので、テンプレートが気にすべきは揃いきったデータをどう表示するかだけです。
つまり「取得したデータを HTML に組み立てるだけ」でいいのです。
React で書いてみる
同じものを React で書いてみます。
ソースコード
エントリーポイントの実装は次のとおりです。
react/index.html
の <div id="root"></div>
内に App
コンポーネントを描画しています。
createRoot(globalThis.document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
)
App
コンポーネントは次のとおり、JSX.Element 型のオブジェクトを返す関数です。
返した JSX.Element が UI として描画されます。
export function App(): JSX.Element {
const params = new URLSearchParams(globalThis.location.search)
const currentPage = parseInt(params.get("page") as string) || 1
const [todos, setTodos] = useState<Todo[] | "LOADING">("LOADING")
useEffect(() => {
;(async () => {
await fakeDelay(1_000)
const todos = await fetch(
`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
).then((r) => r.json())
setTodos(todos)
})()
}, [])
if (todos === "LOADING") {
return <progress>Loading...</progress>
}
return (
<div>
<h1>React Example</h1>
<div style={{ display: "flex", gap: 8 }}>
{[1, 2, 3, 4, 5].map((page) => {
const current = page === currentPage
return (
<a key={page} href={`?page=${page}`} style={{ padding: 8 }}>
{current ? (
<b>
<u>{page}</u>
</b>
) : (
page
)}
</a>
)
})}
</div>
<table style={{ tableLayout: "auto" }}>
<thead>
<tr>
<th>ID</th>
<th>TITLE</th>
<th>COMPLETED</th>
</tr>
</thead>
<tbody>
{todos.map(({ id, title, completed }) => (
<tr key={id}>
<td>{id}</td>
<td>{title}</td>
<td>{completed ? "YES" : "NO"}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
結果は次のとおりです。
JSX 構文の補足
App.tsx
では TypeScript に HTML タグの混じった独特の記法を使いました。
HTML タグが混じっていますが、TypeScript としては妥当な文法です。
HTML タグの部分は JSX といって、_jsx()
/ _jsxs()
という関数の糖衣構文です。
つまり、次のトランスパイル前の TypeScript と、トランスパイル後の JavaScript は同じ内容です。
const element = (
<div>
<h1>HELLO</h1>
<p>world</p>
</div>
)
const element = _jsxs(
"div",
{
children: [
_jsx("h1", { children: "HELLO" }, void 0),
_jsx("p", { children: "world" }, void 0),
],
},
void 0
)
_jsx()
/ _jsxs()
が JSX.Element 型の値を返すので、App
関数は JSX.Element を返す関数になっています。
react/src/App.tsx の変換結果(抜粋)
export function App() {
// ...
return _jsxs(
"div",
{
children: [
_jsx("h1", { children: "React Example" }, void 0),
_jsx(
"div",
{
style: { display: "flex", gap: 8 },
children: [1, 2, 3, 4, 5].map((page) => {
const current = page === currentPage
return _jsx(
"a",
{
href: `?page=${page}`,
style: { padding: 8 },
children: current
? _jsx(
"b",
{ children: _jsx("u", { children: page }, void 0) },
void 0
)
: page,
},
page
)
}),
},
void 0
),
_jsxs(
"table",
{
style: { tableLayout: "auto" },
children: [
_jsx(
"thead",
{
children: _jsxs(
"tr",
{
children: [
_jsx("th", { children: "ID" }, void 0),
_jsx("th", { children: "TITLE" }, void 0),
_jsx("th", { children: "COMPLETED" }, void 0),
],
},
void 0
),
},
void 0
),
_jsx(
"tbody",
{
children: todos.map(({ id, title, completed }) =>
_jsxs(
"tr",
{
children: [
_jsx("td", { children: id }, void 0),
_jsx("td", { children: title }, void 0),
_jsx(
"td",
{ children: completed ? "YES" : "NO" },
void 0
),
],
},
id
)
),
},
void 0
),
],
},
void 0
),
],
},
void 0
)
}
API ロード待ちの UI が必要
この実装で注目すべきは、App
コンポーネントが、API ロード待ちと完了後の 2 つの状態を持っている点です。
テンプレートエンジン版の実装ではロード完了後だけを表示すればよかったのと対照的です。
なぜ 2 つの状態を持つ必要があるのか、具体的に見てみます。
コンポーネントに状態を与えているのは次の部分です。
const [todos, setTodos] = useState<Todo[] | "LOADING">("LOADING")
useEffect(() => {
;(async () => {
await fakeDelay(1_000)
const todos = await fetch(
`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
).then((r) => r.json())
setTodos(todos)
})()
}, [])
useState()
の戻り値(タプル)の第 1 要素 todos
(Todo[]
型)は現在の状態、第 2 要素 setTodos
は状態を変える関数です。
todos
の初期値は useState()
の引数である "LOADING"
です。
useEffect()
は、API コールなどの副作用を実行する仕組みです。
引数の無名関数をレンダリングとは別のキューで実行するイメージです。
このコードによって起きることは次のとおりです。
- 初回は
todos === "LOADING"
としてレンダリングが始まる(progress 要素が表示される)。
API コールの処理はキューに詰めるだけ。 - 初回レンダリング後、API がコールされる。
レスポンスが返る(await fetch
の行の実行が終わる)とsetTodos()
を呼ぶので状態が変わり、再びレンダリングが始まる。 -
todos
に API レスポンスが入った状態でテーブルが描画される。
API のロードを待ってからレンダリングをしたいが、API をコールするためには App
関数を呼び出さねばなりません。
App
関数の呼び出しはレンダリングの開始を意味するので、結局 API コールより先に初回のレンダリングが発生してしまうのです。
もちろん、App
関数の外に API コールを実装する手もありますが、その場合、ドメインの関心が散らばるきらいがあります。
App
の表示に必要な API コールは App
に実装したいものです。
そもそも、API コールを App
から追い出したところで、最終的にどこかのコンポーネントで状態の管理が必要です。
ロード待ちの UI を完全にはなくせないのです。
ちなみに、次のように、コンポーネント内で非同期処理を待つようなことはできません。
Promise を返す関数は React がコンポーネントとして受け付けないのです。
// 👎 NG
export async function App(): Promise<JSX.Element> {
// ...
const todos: Todo[] = await fetch(
`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
).then((r) => r.json())
解決編 - React + Suspense for Data Fetching
問題編で述べた事象を解消するのが、Suspense コンポーネントです。
API ロード待ちの状態をユーザーコードから隠蔽し、「取得したデータを HTML に組み立てるだけ」にしてくれます。
ソースコード
まず、次のように、SWR の設定と Suspense をアプリ全体に適用します。
createRoot(globalThis.document.getElementById("root")!).render(
<StrictMode>
+ <SWRConfig
+ value={{
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ shouldRetryOnError: false,
+ dedupingInterval: 86400_000,
+ suspense: true,
+ fetcher: (url) =>
+ fakeDelay(1_000).then(() => fetch(url).then((r) => r.json())),
+ }}
+ >
+ <Suspense fallback={<progress>Loading...</progress>}>
<App />
+ </Suspense>
+ </SWRConfig>
</StrictMode>
)
SWRConfig
に渡した次の設定で、積極的なデータリフレッシュをオフにしています。
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
dedupingInterval: 86400_000,
そして suspense: true
で Suspense を活用するモードを有効にしています。
fetcher
にはデータ取得のメソッドを渡しています。
SWR の説明を省いたので、設定の意味が伝わらないと思いますが、本質ではないので気にしないでください。
本質は <Suspense fallback={...}>
の部分です。
意味は App
の変更内容を見てから説明します。
続いて、肝心の App
コンポーネントを次のように変更します。
API コールの処理を SWR の useSWR
関数で置き換えています。
export function App(): JSX.Element {
const params = new URLSearchParams(globalThis.location.search)
const currentPage = parseInt(params.get("page") as string) || 1
-
- const [todos, setTodos] = useState<Todo[] | "LOADING">("LOADING")
-
- useEffect(() => {
- ;(async () => {
- await fakeDelay(1_000)
-
- const todos = await fetch(
- `https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
- ).then((r) => r.json())
-
- setTodos(todos)
- })()
- }, [])
-
- if (todos === "LOADING") {
- return <progress>Loading...</progress>
- }
+ const todos: Todo[] = useSWR(
+ `https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
+ ).data
return (
<div>
API ロード待ちの分岐が不要
この実装で注目すべきは、App
コンポーネントから API ロード待ちの分岐がなくなった点です。
useState()
や useEffect()
がなくなってすっきりしたのは、単に useSWR()
内に処理が移ったという見た目だけの変化です。
しかし、useSWR()
が同期的に Todo
オブジェクトの配列を返している点は、見た目だけの変化と言うにはパラダイムシフトが過ぎます。
どのような仕組みなのでしょうか。
同期的な API コールを使っているとか、いったん空の配列が返っているとかではありません。
useSWR()
が Promise をスローするので、変数 todo
への代入がされないまま、処理が App
関数を脱出しているのです。
Promise が解決する、つまり API レスポンスが返ってくると、App
関数が再び呼ばれます。
そのときには、useSWR()
が API レスポンスの値を同期的に返し、JSX.Element を組み立てるところまで処理が進みます。
つまり、次の (1)
- (8)
の順で処理が進むのです。
Promise をスローするのは (3)
のタイミングです。
export function App(): JSX.Element {
// (1) (4)
const params = new URLSearchParams(globalThis.location.search)
// (2) (5)
const currentPage = parseInt(params.get("page") as string) || 1
// (7)
const todos: Todo[] =
// (3) (6)
useSWR(
`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
).data
// (8)
return (
<div>
(3)
から (4)
に処理が戻るまで、つまり API ロード待ちの間は、もっとも近い親の Suspense が UI として表示されます。
<App />
を Suspense で囲ったのはそのためです。
まとめ - React とテンプレートエンジンの比較
あらためてそれぞれのコードを比較してみます。
どちらも「取得したデータを HTML に組み立てるだけ」の宣言的なコードになっています。
テンプレートエンジン版
export async function app(query: Query): Promise<string> {
const currentPage = parseInt(query.page as string) || 1
const todos = await fetch(
`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
).then((r) => r.json())
// ...
return template({
// ...
})
}
React 版
export function App(): JSX.Element {
const params = new URLSearchParams(globalThis.location.search)
const currentPage = parseInt(params.get("page") as string) || 1
const todos: Todo[] = useSWR(
`https://jsonplaceholder.typicode.com/todos?_limit=10&_page=${currentPage}`
).data
return (
<div>
{/* ... */}
</div>
)
}
ウェブ UI を React で書くメリット
データ取得が宣言的に書けてしまえば、ウェブ UI を React で書くメリットが際立ちます。
- JSX が TypeScript に統合されているので、地の文と同じ制御構文が使えたり、型検査や IDE の補完を効かせたりできます。
- サードパーティーのコンポーネントパッケージ(UI パーツ)が豊富です。
- React Bootstrap はおすすめのパッケージの 1 つです。
- ダイアログのような、フロントエンドで状態を持つ UI が使えます。
また、フロントエンドにウェブ UI の組み立てを寄せること自体、実装の観点以外に計算負荷の点や UI 体験の点でメリットとなります。
React を、慣れ親しんだテンプレートエンジンの延長として使ってみてはいかがでしょうか。
リポジトリーや参考記事へのリンク
Discussion