🎅

React をテンプレートエンジンの代わりに使う

2021/12/13に公開約15,700字

フロントエンドの専門家がいないことを理由に、React を使わず、バックエンドのテンプレートエンジンで出力した HTML に jQuery で動きをつける。
最近の React[1] なら、テンプレートエンジンに近い発想でウェブ UI が書けるので、そんなメンテナンス性の低いことをする必要はありません。

テンプレートエンジンでウェブ UI が書ける人なら React でも書けるし、むしろメリットがある。
そう思ってもらうのが目的の記事です。[2]

作るサンプル

次のようなウェブ UI を作ってみます。

React Example

次の JSON API のレスポンスを表示するだけのシンプルなものです。

jsonplaceholder.typicode.com/todos?_limit=10&_page=1
[
  {
    "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 にあります。

https://github.com/kazuma1989/react-vs-template-engine

テンプレートエンジンで書いてみる

Handlebars を使う

テンプレートエンジンとして、Node.js で扱いやすい Handlebars を使います。

テンプレートエンジンには、ほかにも Blade (PHP), eRuby (Ruby), Twirl (Scala) などがあります。
しかし、データを取得してから HTML を出力する流れは Handlebars も含めみな同じです。
バックエンドは、HTML 出力後に描画を変えられない、つまり最終系の HTML を出力するほかないからです。

ソースコード

ルーティング部分の実装は次のとおりです。
URL パス / に対して app 関数の結果を返すようになっています。
読み込みの遅延をあえて入れています。

handlebars/src/index.ts(抜粋)
  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 を返しています。

handlebars/src/app.ts(抜粋)
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 による制御文を使っています。

handlebars/src/app.hbs
{{#> 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}}

結果は次のとおりです。

Handlebars Example

API コールを待てている

この実装で注目すべきは、HTML を組み立て始めるまで API コールを待てている点です。

非同期処理を待てるので、テンプレートが気にすべきは揃いきったデータをどう表示するかだけです。
つまり「取得したデータを HTML に組み立てるだけ」でいいのです。

React で書いてみる

同じものを React で書いてみます。

ソースコード

エントリーポイントの実装は次のとおりです。
react/index.html<div id="root"></div> 内に App コンポーネントを描画しています。

react/src/index.tsx(抜粋)
createRoot(globalThis.document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
)

App コンポーネントは次のとおり、JSX.Element 型のオブジェクトを返す関数です。
返した JSX.Element が UI として描画されます。

react/src/App.tsx(抜粋)
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>
  )
}

結果は次のとおりです。

React Example

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 の変換結果(抜粋)
JSX.Element 部分抜粋
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 つの状態を持つ必要があるのか、具体的に見てみます。

コンポーネントに状態を与えているのは次の部分です。

react/src/App.tsx(抜粋)
  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 要素 todosTodo[] 型)は現在の状態、第 2 要素 setTodos は状態を変える関数です。
todos の初期値は useState() の引数である "LOADING" です。

useEffect() は、API コールなどの副作用を実行する仕組みです。
引数の無名関数をレンダリングとは別のキューで実行するイメージです。

このコードによって起きることは次のとおりです。

  1. 初回は todos === "LOADING" としてレンダリングが始まる(progress 要素が表示される)。
    API コールの処理はキューに詰めるだけ。
  2. 初回レンダリング後、API がコールされる。
    レスポンスが返る(await fetch の行の実行が終わる)と setTodos() を呼ぶので状態が変わり、再びレンダリングが始まる。
  3. 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 をアプリ全体に適用します。

react/src/index.tsx(抜粋)
 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 関数で置き換えています。

react/src/App.tsx(抜粋)
 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) のタイミングです。

react/src/App.tsx(抜粋)
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 に組み立てるだけ」の宣言的なコードになっています。

テンプレートエンジン版

handlebars/src/app.ts(抜粋)
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 版

react/src/App.tsx(抜粋)
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 パーツ)が豊富です。
  • ダイアログのような、フロントエンドで状態を持つ UI が使えます。

また、フロントエンドにウェブ UI の組み立てを寄せること自体、実装の観点以外に計算負荷の点や UI 体験の点でメリットとなります。

https://speakerdeck.com/dena_tech/techcon2021-17

React を、慣れ親しんだテンプレートエンジンの延長として使ってみてはいかがでしょうか。

リポジトリーや参考記事へのリンク

https://github.com/kazuma1989/react-vs-template-engine

https://speakerdeck.com/dena_tech/techcon2021-17

https://note.com/erukiti/n/n6f673021469e

https://qiita.com/uhyo/items/255760315ca61544fe33

https://ja.reactjs.org/docs/concurrent-mode-suspense.html

https://github.com/reactwg/react-18/discussions/47
脚注
  1. バージョン 17 以降かつ、実験的な API (Suspense for Data Fetching) を使います。 ↩︎

  2. Server Side Rendering (SSR) ではなく、フロントエンドで Single Page Application (SPA) 風に実装します。 ↩︎

Discussion

ログインするとコメントできます