RemixのSSRを超シンプルなアプリで確認する

Remixが行なっているSSRの処理を超シンプルなアプリで確認してみました。

やりたいことは、下記のページとほぼ同じです。
最新のRemixのバージョンではこのサイトの説明通りでは動作しないところがあったため、自前でやってみました。

https://kentcdodds.com/blog/super-simple-start-to-remix

1. Remix本家のQuick Startのテンプレートをダウンロードする
1. テンプレートからSSRの動作確認に不要なファイルとコードを削除し、超シンプルなアプリに書き換える
1. サーバサイドとクライアントサイドでRemixが行う処理のエントリポイントにログを仕込む
1. root.tsxを使ってSSRの動作を確認していく
    1. Hookがない静的なコンポーネント
    1. Reactの標準的なHook（useState）を含むコンポーネント
    1. RemixのHook（useLoaderData）を含むコンポーネント

# Quick Startテンプレートをダウンロードする。

Remix本家のQuick Startページの説明に従ってテンプレートをダウンロードし、ビルド、動作確認をします。

https://remix.run/docs/en/main/start/quickstart


``` bash
npx create-remix@latest remix-minimal
cd remix-minimal
npm run dev
```

# 超シンプルなアプリに書き換える

`app/root.tsx`を超シンプルな静的なコンテンツに書き換えます。

``` tsx:root.tsx
import {
  Scripts,
} from "@remix-run/react";


export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
      </head>
      <body>
        <h1>Hello, Remix-Minimal</h1>
        <Scripts />
      </body>
    </html>
  );
}
```

さらに`routes`ディレクトリ、CSSに関するファイル、ロゴ画像などのリソースファイルを削除します。

``` bash
rm -rf ./app/routes
rm ./app/tail
rm ./app/tailwind.css
rm ./tailwind.config.js
rm ./postcss.config.js
rm ./public/*.png
```

下記のようなディレクトリ構成になります。

![minimal-files.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/85bb0750-5945-6683-bd4f-b39f1513025f.png)

画面表示は下記です。

![minimal-screen.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/ed38e5f8-b0ef-b632-30d9-ac27aeca822b.png)

# レンダリング処理のエントリポイントにログを仕込む

Remixで行われるレンダリング処理のエントリポイントは下記です。

- サーバーサイド: app/entry-server.tsx
- クライアントサイド: app/entry-client.tsx

これらの処理が動いていることを確認するためにログを入れておきます。
entry-server.tsxはisbot()の処理の削除も行なっておきます。

``` tsx:app/entry-server.tsx
import { PassThrough } from "node:stream";

import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5_000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext,
  // This is ignored so we can keep it in the template for visibility.  Feel
  // free to delete this parameter in your app if you're not using it!
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  loadContext: AppLoadContext
) {
    return handleBrowserRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      );
}

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return new Promise((resolve, reject) => {

    console.log(`Remix SSR ${request.url}`);
    
    let shellRendered = false;
    const { pipe, abort } = renderToPipeableStream(
      <RemixServer
        context={remixContext}
        url={request.url}
        abortDelay={ABORT_DELAY}
      />,
      {
        onShellReady() {
          shellRendered = true;
          const body = new PassThrough();
          const stream = createReadableStreamFromReadable(body);

          responseHeaders.set("Content-Type", "text/html");
          resolve(
            new Response(stream, {
              headers: responseHeaders,
              status: responseStatusCode,
            })
          );

          pipe(body);
        },
        onShellError(error: unknown) {
          reject(error);
        },
        onError(error: unknown) {
          responseStatusCode = 500;
          // Log streaming rendering errors from inside the shell.  Don't log
          // errors encountered during initial shell rendering since they'll
          // reject and get logged in handleDocumentRequest.
          if (shellRendered) {
            console.error(error);
          }
        },
      }
    );

    setTimeout(abort, ABORT_DELAY);
  });
}
```

``` tsx:app/entry.client.tsx
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";

startTransition(() => {
  console.log(`Remix Hydration ${document.URL}`);
  hydrateRoot(
    document,
    <StrictMode>
      <RemixBrowser />
    </StrictMode>
  );
});
```

この状態で`npm run dev`を実行してサーバーを再起動し、ブラウザから`http://localhost:5173`にアクセスします。アクセス時はキャッシュをクリアするために、強制リロードをします。（Safariの場合はShiftボタンを押しながらリロード）

サーバ側に下記のようなログが出力されます。SSRの処理が動いていることがわかります。

``` bash
Remix SSR http://localhost:5173/
```

クライアント側であるブラウザの開発ツールのコンソールには下記のようなログが出力されます。ハイドレーションの処理が動いていることが確認できます。

![hydration001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/cc5f7118-31b4-05b9-666d-e4b64d433ae2.png)


# root.tsxを使ってSSRの動作を確認していく

root.tsxを使ってSSRの動作を確認していきます。

## Hookがない静的なコンポーネント

まずはHookがない静的なコンポーネントです。
現在のroot.tsxはこの状態になっています。

``` tsx:root.tsx
export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
      </head>
      <body>
        <h1>Hello, Remix-Minimal</h1>
        <Scripts />
      </body>
    </html>
  );
}
```

ブラウザからアクセスすると、SSRとHydrationのログがそれぞれ出ていることが確認できます。また、ブラウザにダウンロードされたHTMLのソースコードを見ると下記のようになっています。root.txsのJSXがHTMLにレンダリングされた結果がダウンロードされていることがわかります。

![html001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/c8ced57a-09e7-def7-81f3-dfe78b6e6d68.png)

# useState Hookを含むコンポーネント

次にuseState Hookを使ってみます。root.tsxを下記のように書き換えます。

``` tsx:root.tsx
import {
  Scripts,
} from "@remix-run/react";

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
      </head>
      <body>
        <h1>Hello, Remix-Minimal</h1>
        <h2>Counter: {count}</h2>
        <button onClick={() => setCount((c) => c + 1)}>Increment Counter</button>
        <Scripts />
      </body>
    </html>
  );
}
```

ブラウザからアクセスすると、SSRとHydrationのログがそれぞれ出ていることが確認できます。

ブラウザにダウンロードされたHTMLのソースコードを見ると下記のようになっています。

``` html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charSet="utf-8"/>
</head>
<body>
    <h1>Hello, Remix-Minimal</h1>
    <h2>
        Counter: <!-- -->
        0
    </h2>
    <button>Increment Counter</button>
    ...
</body>
```

出力されたHTMLは下記のような静的なものになっています。
- count変数は初期値0でレンダリングされています
- buttonにはonClickハンドラが登録されていません

ブラウザの開発ツールでDOMを確認すると、ハイドレーションによって、buttonにイベントハンドラが登録されていることがわかります。

![element002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/eb17594c-2b89-e499-1003-edc9ab861f2c.png)

buttonをクリックすると、通常のReactコンポーネントと同じようにHookが動作して、counter変数がインクリメントされていることが確認できます。

![screen002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/c64fd7d3-e32f-ef24-631a-6c83c3688769.png)

## useLoaderData Hookを含むコンポーネント

root.tsxにRemixのHookであるuserLoaderData Hookを追加してみます。

``` tsx:root.tsx
import {
  Scripts,
  useLoaderData,
} from "@remix-run/react";

import { useState } from "react";

export const loader = () => {
  console.log("Remix loader root.tsx");
  return ({ items: [
    { id: 1, name: "apple" },
    { id: 2, name: "orange" },
    { id: 3, name: "melon" },
  ]});
}

export default function App() {

  const { items } = useLoaderData<typeof loader>();
  console.log(`items: ${JSON.stringify(items)}`);

  const [count, setCount] = useState(0);
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
      </head>
      <body>
        <h1>Hello, Remix-Minimal</h1>
        <h2>Counter: {count}</h2>
        <button onClick={() => setCount((c) => c + 1)}>Increment Counter</button>
        <ul>
          {items.map((item) => (
            <li key={item.id}>{item.name}</li>
          ))}
        </ul>
        Item[{count}]: {items[count]?.name || "none"}
        <Scripts />
      </body>
    </html>
  );
}
```

ブラウザからアクセスすると、SSRとHydrationのログがそれぞれ出ていることが確認できます。

ブラウザにダウンロードされたHTMLのソースコードを見ると下記のようになっています。

``` html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charSet="utf-8"/>
</head>
<body>
    <h1>Hello, Remix-Minimal</h1>
    <h2>
        Counter: <!-- -->
        0
    </h2>
    <button>Increment Counter</button>
    <ul>
        <li>apple</li>
        <li>orange</li>
        <li>melon</li>
    </ul>
    Item[<!-- -->
    0<!-- -->
    ]: <!-- -->
    apple
    ...
</body>
...
<div hidden id="S:0">
    <script>
    window.__remixContext.streamController.enqueue("
    [
        {\"_1\":2,\"_18\":-5,\"_19\":-5},
        \"loaderData\",
        {\"_3\":4},
        \"root\",
        {\"_5\":6},
        \"items\",
        [7,12,15],
        {\"_8\":9,\"_10\":11},
        \"id\",
        1,
        \"name\",
        \"apple\",
        {\"_8\":13,\"_10\":14},
        2,
        \"orange\",
        {\"_8\":16,\"_10\":17},
        3,
        \"melon\",
        \"actionData\",
        \"errors\"
    ]\n");
    </script>
    <!--$?-->
    <template id="B:1"></template>
    <!--/$-->
</div>
...
```

出力されたHTMLは下記のようになっています。

- loader関数で取得したitemsの情報が箇条書き(li)要素に展開されている
- loader関数で取得したitemsの情報がloaderDataとしてStreamControllerにエンキューされている

loader関数で取得したデータは、HTMLの中と、JavaScriptの中の2箇所に存在しています。

後者のデータは、クライアントサイドのJavaScriptからもuseLoaderData()の戻り値として利用することができます。root.tsxの下記の部分はクライアント側でIncrement Counterボタンが押されるごとに、データを参照して表示を書き換えています。

``` tsx:root.tsx
Item[{count}]: {items[count]?.name || "none"}
```

![screen14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/57f4a25a-7f96-b5cb-a3b5-10f3bf467105.png)

loaderが返すデータには、.dataのsuffixをつけたURLからもアクセスができます。
今回の場合、`http://localhost:5173/.data`にアクセスすると下記のデータが返ってきます。
これはuseFetcher Hookを使ってfetcher.load()関数を呼び出した時にアクセスするURLと同じです。

![screen_data.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/39769f4f-72da-bd14-8162-28c187629656.png)

# まとめ

RemixではすべてのReactコンポーネントに対してサーバーサイドでのレンダリング処理（SSR）の処理と、クライアントサイドでのハイドレーション処理が行われます。
これらの処理のエントリポイントは`app/entry.server.tsx`と`app/entry.client.tsx`です。



サーバーサイドレンダリングはtsxファイルを評価し、HTMLを出力する処理です。ハイドレーションはダウンロードしたHTMLから構築したDOMと、Reactの仮想DOMを同期する処理です。ハイドレーションを行った後は、通常の（いわゆるSPAの）Reactアプリケーションと同じようにインタラクティブな処理が有効になります。

SSRでloader関数を使ってロードしたデータは、HTML内のJavaScriptコードに埋め込まれます。ハイドレーション後、ブラウザ上のReactコンポーネントはそれらのデータにアクセスすることができます。

これらのデータには、通常のURLに`.data` suffixをつけたURLから直接アクセスすることもできます。useFetcher HookではこのURLにアクセスしてloadされたデータを取得します。返ってくるデータはloader関数でreturnされているJSONオブジェクトではなく、turbo-stream形式(?)のデータです。

# 考察（Remixとマイクロサービス）

Remixではサーバサイドの処理とクライアントサイドの処理が一つのファイルに実装されます。
そのため、Remixにおけるサーバサイドの処理はあくまでそのアプリ専用の処理と考えるのが良いと思います。
そのアプリだけで使うデータベースにアクセスする処理や、そのアプリのデータを引数にして外部のAPIにアクセスするような処理が適しています。

いわゆるマイクロサービスに基づく設計では、APIサービスと、それらを利用してユーザにアプリケーションを提供するサービスが複数存在します。Remixは後者のアプリケーションサービスを実現するために適したフルスタックフレームワークであると言えます。RemixでもAPIサービスを実装することができますが、Remixを使う必然性はありません。RemixはUIを持つアプリケーションの実装でその効果を発揮するためです。

したがって、マイクロサービス設計のシステムにおいては、下記が適切な設計であると考えます。

- APIサービスはRemixとは別の、よりAPIの実装に適したランタイムとフレームワークを用いて実現する
- UIを伴うアプリケーションサービスの実装にはRemixを利用し、Remixのloader関数やaction関数からAPIサービスを利用する形で連携する

![remixapp.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/126781/386e06f2-ab45-3825-a363-45cf9ed7091d.png)

# リソース

この記事で作成したコードは下記からダウンロードできます。

https://github.com/kizitorashiro/remix-minimal