🧚

2021年からReactを始めるなら React Server Components 一択ではないか?(コラム追加版)

47 min read

コラム(ポエム1〜12)を追加した記事です。
コラムでは React Server Components の認証や多言語対応などに触れています。

Next.jsも本腰を入れてReact Server Componentsの対応に動いているようです。

https://twitter.com/wongmjane/status/1442955349602942978

vercel/next.jsのReact Server Components対応を含むPull Request。

https://github.com/vercel/next.js/pull/29470
+        ...(isFlight
+          ? [
+              {
+                test: /\.client\.(js|ts)x?$/,
+                use: {
+                  loader: 'next-react-flight-loader',
+                },
+              },
+            ]
+          : []),
+        ...(!isServer
+          ? [
+              {
+                test: /\.server\.(js|ts)x?$/,
+                use: {
+                  loader: 'next-react-flight-exclude-server-loader',
+                },
+              },
+            ]
+          : []),

※Next.jsではtsxファイルに対応しているもよう。

はじめに

https://www.youtube.com/watch?v=eRAD3haXXzc

今からReactを始めるなら、React Server Components がおすすめです。

パラダイムシフトが起き、ここ5年ほどでSSR(Server Side Rendering:PHPやRuby on Railsなど)からSPA(Single Page Application:ReactやVueなど)に変わっていきました。
今後はさらに、SPAとSSRの良いとこ取りに移行しようとしています。

React Server Components は サーバーサイドのReact ReactDOMServer.renderToString(element) を応用し、実験的とされていた SuspenseTransition 等を用い、ひとまとめに(フレームワークのように)整理したものです。

React Server Components で開発したサービス

React Server Components で開発したサービスです。
https://studywithme.cmsvr.live/

目次

はじめに
自己紹介
Reactのバージョン
デモのインストール
必要なファイル以外を削除
予習・復習
React Sever Components の種類
ネーミング(命名規約)の注意点
ここから始まるApp.server.js
最初にサーバーコンポーネントを作成
他資源へのアクセス
クライアントコンポーネントを作成
React Server Components のメリット
Suspense
トランジション
クライアントコンポーネントからサーバーコンポーネントに値を渡す
fetchによるREST API処理
外部のDBを利用する
Webサーバー(express)のポートを変更する
スケールアウトについて
検証
演習
課題

コラム
☕️ポエム 1: SSR経験者ならReact Server ComponentsでSPAが簡単に作れる話
☕️ポエム 2: JavaScriptを巡る主導権争い
☕️ポエム 3: 今までのSPA
☕️ポエム 4: ウォーターフォール(Waterfall)問題とは?
☕️ポエム 5: React Server Components は どの立ち位置か
☕️ポエム 6: React Server ComponentsのRFC
☕️ポエム 7: React Server Componentsの副産物的なメリット
☕️ポエム 8: React Server Componentsの重要なキーワードを整理
☕️ポエム 9: 基本はSSR(PHPやRails等)の考えでつくることができる
☕️ポエム 10: React Server Components 作り方の基本
☕️ポエム 11: React Server Componentsの認証
☕️ポエム 12: React Server Componentsの多言語対応

さいごに
ブック(応援)
ブック以外で閲覧するには

サイドバー(👉)にある目次もご利用ください。

自己紹介

5年前(React v0.1の時代)に、下の記事を書いたものです。VTeacherというサービスを開発するエンジニアをしています。昔はCyberAgentやGREEにいました。社長もやっています。CTOはやっていません(別の役員に任せています)。

https://prtimes.jp/main/html/rd/p/000000002.000051650.html
  • 今までやってきたこと
分野 内容 内容
Front Mobile Objective-C, Swift, Java, Kotlin, Android NDK(C++), RxSwift, RxJava, VIPER, Realm
Web HTML, CSS, JavaScript, Node.js, npm, yarn, Babel, Webpacker, JQuery
VR/AR/MR Unity(C#), C, C++, Java, Three.js, WebGL, Oculus(Rift/GearVR/Go/Quest), THETA(S/V)
Back PHP PHP(Ethna/Codeigniter/Zend Framework), Linux, Apache, Nginx, MySQL, Postgresql, WordPress, WebAPI(REST), PHPUnit, Selenium, Codeception
JVM Java(JSP/EJB/Struts/Velocity/Seasar2), Scala, JavaScript, Oracle, MySQL, Solaris, Linux(RedHat/CentOS), JRun, HitachiCosmiNexus, Web Logic, Tomcat, Play, Apache, Nginx, WebAPI(REST), JUnit, Jmeter
Infrastructure AWS(EC2, Lambda, Batch, ECR, ECS, EKS, S3, RDS, VPC, CloudFront, Route 53, API Gateway, Cloud9, MediaLive, MediaPackage, MediaStore, Elastic Transcoder, IAM, Certificate Manager, Key Management Service, Simple Email Service)
DevOps Docker, Jenkins, CircleCI, GitHub Actions
Shell, Ruby, Perl, VB, VBA, VB.NET, ASP

今回はこの投稿の続編となります。
前回と同様、「少し早めのキャッチアップ」がコンセプトです。

Reactのバージョン

2020年12月にFacebookからReact Server Componentsのデモが公開されました。

現在のReactのバージョンは18ですが、React Server Componentsの正式導入は19以降と予想されています。これまでにReact Server Componentsのための布石と言えるような実験的な機能がリリースされてきました。業界人の予想通り、全てがReact Server Componentsのためであれば、今までの常識も変わるでしょうから、先入観がないほうが受け入れやすいと思います。

Reactチームが出したデモコードを解析しながら、チームで便利なちょっとしたWebアプリを作ってみたらいかがでしょうか?
DBはPostgreSQLを使います。ちなみに Relay + GraphQL は、React Server Componentsのデモでは対応バージョンの関係で利用できません。

  • 注1. TypeScriptの導入欲求はいったん抑えておきましょう。
    • React Server Componentsは複雑に入り組んでいるため、tsファイル化に時間を取ることはあまりおすすめできません。代わりに、VS Codeを使用した //@ts-check コメントを検討しましょう。ファイルの先頭に記述すると、普通のJavaScriptであっても型チェックをしてくれます。本格的にやる場合は JSDoc@param@returns を記述しましょう。
  • 注2. Next.jsの採用欲求はいったん抑えておきましょう。
    • Next.js用のReact Server Componentsのデモが別に出ています。本家(Facebook)がNext.jsの得意領域に進出したとも見え、共存方法を模索中であるような段階です。※Next.jsのRSCはtsxファイルが使えるもようです。

デモのインストール

デモのインストール方法はREADMEを参照してください。Dockerの利用が手早いと思います。

またはこちらの記事を参考に。

localhostで確認ができたら、次に進みましょう。

このデモをスケルトンとして、自作のコンポーネントを追加していきます。

必要なファイル以外を削除

src以下は下記のものを残して、残りのファイルは削除で大丈夫です。

  • App.server.js
  • Root.client.js
  • Cache.client.js
  • db.server.js
  • LocationContext.client.js
  • index.client.js

予習・復習

Reactの書き方。初めての方、久しぶりの方に向けて。基本構文はこちらです。

export default function Hoge() {
    return (
        <div>
            This is Hoge.
        </div>
    );
}

Hogeというファイル名で、これを定義することにより、 <Hoge /> のようにタグ表記できるようになります。<Hoge /> の中身は、returnに記述されているHTMLで、Webブラウザから見ると、それが表示されます。このあたりの技術をJSXと言い、Facebookが開発しています。returnには他のコンポーネントも記述できます。

React Sever Components の種類

React Sever Components は通称です。利用にあたり3種類のファイルを使用します。

  • サーバーコンポーネント
    • ファイル名の命名規則は .server.js
    • サーバー側でレンダリング
    • 他資源へのアクセス(react-fetchからREST API等、react-pgからDB参照、将来的にはRelay+GraphQL等)
  • クライアントコンポーネント
    • ファイル名の命名規則は .client.js
    • クライアント側でレンダリング
    • 他資源へのアクセス(react-fetchからREST API等)
    • 通常のReactコンポーネントと同じく、stateが使えます。
  • 共通コンポーネント
    • ファイル名の命名規則は .js
    • サーバー側でもクライアント側でも利用可能なコンポーネントです。共通処理。

ネーミング(命名規約)の注意点

ToDOというコンポーネントを考えた時、つい、下記のようなファイル構成にしてしまいました。

  • ToDo.server.js
  • ToDo.client.js
  • ToDo.js

しかし、これはimportの際、defaultの名前が重複してしまうので(この場合はToDo。import時に名前を設定できるけど)、おすすめしません。Facebookのデモもこのような構成になっていません。
コンポーネントをきちんと設計して、コンポーネント単位で分けましょう。

サーバーコンポーネントしか許されていない処理をクライアントコンポーネントで実行すると、エラーになります。

例:クライアントコンポーネントでdb(react-pg)を利用する場合、実行時に TypeError: Cannot read property 'db' of undefined になってしまう。

import {db} from './db.server'
()
const notes = db.query(
    `select * from notes where title ilike $1`,['%%']
).rows;

最初はサーバーコンポーネントだけを用意したほうがやりやすいです。
その中でクライアントコンポーネントにできるもの・する必要があるものは変更します。

ここから始まるApp.server.js

React Server Componentsは、ここからはじまります。このファイルにサーバーコンポーネントを記述します。

現時点では、とりあえず、このようにしておきましょう。

  • App.server.jsの修正
export default function App({selectedId, isEditing, searchText}) {
  return (
    <div>
    </div>
  );
}

最初にサーバーコンポーネントを作成

自作のコンポーネントを追加していきましょう。

まずはサーバーコンポーネントを用意しましょう。先ほど説明した通り、最初はサーバーコンポーネントだけを用意して、その後にクライアントコンポーネントにできるもの・する必要があるものを探していきましょう。

srcディレクトリ直下に Hoge.server.js を作成し、下記のコードをコピーしてください(サーバーコンポーネントなので、規則に沿って server.js になります)。

  • src/Hoge.server.js(新規作成します)
export default function Hoge() {
    return (
        <div>
            This is Hoge.server.js!
        </div>
    );
}

このHoge(Hoge.server.js)をApp.server.jsに記述します。

  • src/App.server.js(既に存在するので変更して保存)
import Hoge from './Hoge.server';

export default function App({selectedId, isEditing, searchText}) {
  return (
    <div className="main">
        <Hoge />
    </div>
  );
}

サーバーコンポーネントはサーバー側でレンダリングされます。現時点では通常のSSR(PHPやRuby on Rails)と変わりません(のちほどクライアントコンポーネントを作成します)。

他資源へのアクセス

サーバーコンポーネントは、db(react-pg)にアクセスできます(ただしdbへの直接アクセスはアプリ設計上は推奨されていません)。
REST APIの利用にあたってはfetch(react-fetch)が利用できます。fetchはクライアントコンポーネントからも利用できますが、重い処理となりそうなところはサーバーコンポーネントで処理することでクライアントに返すデータ量を削減できます(React Server Componentsの目標であるバンドルサイズゼロ)。

Hoge.server.jsを下記のように変更してみましょう。
Webブラウザで確認するとdb・fetchで取得した値が表示されると思います。

  • src/Hoge.server.js(変更してみましょう)
import {db} from './db.server'; // db(react-pg)
import {fetch} from 'react-fetch'; // fetch(react-fetch)

export default function Hoge() {
    // db
    const notes = db.query(
        `select id from notes`
    ).rows;

    // fetch
    const note = fetch(`http://localhost:4000/notes/1`).json();
    let {id, title, body, updated_at} = note;

    return (
        <div>
            <p>db:</p>
            <ul>
                {notes.map((note) => (
                    <li>{note.id}</li>
                ))}
            </ul>
            <p>fetch:</p>
            {id}{title}{body}{updated_at}
        </div>
    );
}

「実験」

Hoge.server.jsをコピーしてHoge.client.jsを作成してみましょう。
App.server.jsのimportをHoge.clientにしてみましょう。
実行時に TypeError: Cannot read property 'db' of undefined になります。
(fetchは可能です)
実験後は元に戻しておきましょう(App.server.jsのimportをHoge.serverに戻す)。

クライアントコンポーネントを作成

サーバーコンポーネントとクライアントコンポーネントを入れ子にして記述してみましょう。React Server Components は、原則、サーバーコンポーネントから始まります。
下記のようなコンポーネント設計をしてみます。

- ServerComponentHello (Hello.server.js)
    ∟ ClientComponentLeft (Left.client.js)
- ServerComponentWorld (World.server.js)
    ∟ ClientComponentRight (Right.client.js)
  • src/App.server.js(変更してみましょう)
import Hello from './Hello.server';
import World from './World.server';

export default function App({selectedId, isEditing, searchText}) {
  return (
    <div className="main">
        <Hello />
        <World />
    </div>
  );
}
  • src/Hello.server.js(新規作成します)
    サーバーコンポーネント。dbから値を取得し、子となるクライアントコンポーネント(Left)に引き継ぎます。
import {db} from './db.server';
import Left from './Left.client';

export default function Hello() {
    const notes = db.query(
        `select id from notes`
    ).rows;

    let text = "";
    notes.map((note) => {
        text += `${note.id},`;
    });

    return (
        <Left text={text} />
    );
}
  • src/World.server.js(新規作成します)
    サーバーコンポーネント。fetchで値を取得し、子となるクライアントコンポーネント(Right)に引き継いでいます。
import {fetch} from 'react-fetch';
import Right from './Right.client';

export default function World() {
    const note = fetch(`http://localhost:4000/notes/1`).json();
    let {id, title, body, updated_at} = note;
    let text = `${id}${title}${body}${updated_at}`;

    return (
        <Right text={text} />
    );
}
  • src/Left.client.js(新規作成します)
    クライアントコンポーネント。渡された値を左側に表示します(cssで設定)。
export default function Left({text}) {
    return (
        <div className="left">
            {text}
        </div>
    );
}
  • src/Right.client.js(新規作成します)
    クライアントコンポーネント。渡された値を右側に表示します(cssで設定)。
export default function Right({text}) {
    return (
        <div className="right">
            {text}
        </div>
    );
}
  • public/style.css(既存ファイルを変更します。※末尾に追記)
.left {
  float: left;
  width: 50%;
}

.right {
  float: right;
  width: 50%;
}

Webブラウザから確認しましょう。

http://localhost:4000/

下記のように表示されると思います。

1,2 ...(略)                1Meeting ...(略)

「補足」
ちなみにClientComponentの子にServerComponentを置いてもエラーにはなりませんが、そのServerComponentからはdbへのアクセスができません(fetchはできます)。

- ServerComponentHello (Hello.server.js)
    ∟ ClientComponentLeft (Left.client.js)
        ∟ ServerComponentWorld (World.server.js) ※dbアクセス不可能
    ∟ ClientComponentRight (Right.client.js)

React Server Components のメリット

SSRとSPAの良いとこどり。
React Server Components は「レンダリングのパフォーマンス改善(目標バンドルサイズゼロ)」がメリットです。
(React Server Components を利用するだけで表示が軽くなるわけではなく、SPAにおけるWarterFall問題など、コンポーネント設計はきちんとする必要があります)

Reactサーバーコンポーネントの利点の1つは、開発者が単一の言語とフレームワークを使用してアプリケーションを記述し、サーバーとクライアント間でコードを共有できることです。

「実験」
わざと遅延を発生させてみましょう。

React Server Componentsのデモにはfetch用のsleepが用意されています。
これを実行して、わざと遅延を生じさせます。

  • src/World.server.js(変更しましょう)
import {fetch} from 'react-fetch';
import Right from './Right.client';

export default function World() {
    let _ = fetch(`http://localhost:4000/sleep/3000`); // 3秒の遅延
    
    const note = fetch(`http://localhost:4000/notes/1`).json();
    let {id, title, body, updated_at} = note;
    let text = `${id}${title}${body}${updated_at}`;

    return (
        <Right text={text} />
    );
}

Webブラウザで確認してみましょう。
3秒後に表示されるようになったと思います。

「検証」
WebブラウザにChromeを使用し、Chromeの開発ツール(右クリックで検証)を開き、Networkタブを選択し、react?location=... の Previewを見ると、サーバー側からクライアント側に返されるデータを見ることができます。

Suspense

今までの実験的機能はReact Server Componentsのために用意されてきたとも言われています。それらの実験的機能はデモに利用されています。これをTIPSとして紹介していきます。

Suspense(サスペンス)はReact16で導入された実験的な機能です。
コードのロードを「待機」して宣言的にロード中状態(スピナーのようなもの)を指定することができます。

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

デモに従い、 <Suspense /> を利用しましょう。

import {Suspense} from 'react'; を行い、3秒の遅延処理がある <World /> を、 <Suspense> ... </Suspense> で囲みます。<Suspense> のfallbackには待ち時間に表示しておくタグを渡しておきます。

import {Suspense} from 'react';

import Hello from './Hello.server';
import World from './World.server';
import Right from "./Right.client";

export default function App({selectedId, isEditing, searchText}) {
    return (
        <div className="main">
            <Hello />
            <Suspense fallback={<Right text={"This is suspense."} />}>
                <World />
            </Suspense>
        </div>
    );
}

Webブラウザで確認してみましょう。
今度は、最初に This is suspense. が表示され、3秒後に完全なページが表示されるようになったと思います。

トランジション

ボタンを押した時など、画面が再表示される際、一瞬だけ白い画面がチラっと見えたり、さっきまで表示されていた情報が見られなくなったりなど、画面の更新のタイミングを調整したい場合があります。
このような「見せたくない処理」をスキップして、新しい画面に切り替え (transition) する前に新しいコンテンツがロードされるのを調整(待機)できます。

実際にやってみると一目瞭然です。
再描画の処理を入れてみましょう。トランジションを使うパターンと使わないパターンを用意して、比較してみます。

  • src/Left.client.js(変更してみましょう)
import {useTransition} from 'react';
import {useLocation} from './LocationContext.client';

export default function Left({text}) {
    const [location, setLocation] = useLocation();
    const [, startTransition] = useTransition();

    let idNext = location.selectedId + 1;

    return (
        <div className="left">
            <p>id={location.selectedId}</p>
            <button
                onClick={() => {
                    setLocation((loc) => ({
                        selectedId: idNext,
                        isEditing: false,
                        searchText: loc.searchText,
                    }));
                }}>
                Next id={idNext}
            </button>
            <button
                onClick={() => {
                    startTransition(() => {
                        setLocation((loc) => ({
                            selectedId: idNext,
                            isEditing: false,
                            searchText: loc.searchText,
                        }));
                    });
                }}>
                Next id={idNext} (Transition)
            </button>
            <p>{text}</p>
        </div>
    );
}

トランジションを使ったほうが、より自然な画面遷移になると思います。
トランジションを使用しない場合だと、Rightコンポーネントが、Nextボタンを押すたびに「This is suspense.」と表示されてしまいます。
Rightコンポーネントは意図的に3秒遅延処理を入れているため、トランジションの使用にかかわらず、新しいデータが表示されるまでに3秒待つことになります。

クライアントコンポーネントからサーバーコンポーネントに値を渡す

サーバー側で値を引き継ぐ方法です。
Facebookのデモでは、Appが3つの引数( {selectedId, isEditing, searchText} )を取っています。
これは、先程のトランジションについてのクライアントコンポーネントのコード(LocationContext.clientのsetLocation関数)と関連しています。

        setLocation((loc) => ({
            selectedId: idNext,
            isEditing: false,
            searchText: loc.searchText,
        }));

これにより、クライアントからサーバーへ値を引き渡すことができます。

サーバーコンポーネントの <Hello /><World /> に、selectedIdを引き継ぎましょう。 selectedId={selectedId} のように記述します。

  • src/App.server.js(変更します)
import {Suspense} from 'react';

import Hello from './Hello.server';
import World from './World.server';
import Right from "./Right.client";

export default function App({selectedId, isEditing, searchText}) {
    return (
        <div className="main">
            <Hello selectedId={selectedId} />
            <Suspense fallback={<Right text={"This is suspense."} />}>
                <World selectedId={selectedId} />
            </Suspense>
        </div>
    );
}

<Hello /><World /> もselectedIdが参照できるように変更します。せっかくselectedIdが参照できるようになったので、fetch・dbに利用しましょう。

  • src/Hello.server.js(変更します)
import {db} from './db.server';
import Left from './Left.client';

export default function Hello({selectedId}) {
    const notes = db.query(
        `select id from notes where id=$1`, [selectedId]
    ).rows;

    let text = selectedId;
    notes.map((note) => {
        text = note.id;
    });

    return (
        <Left text={text} />
    );
}
  • src/World.server.js(変更します)
import {fetch} from 'react-fetch';
import Right from './Right.client';

export default function World({selectedId}) {
    let _ = fetch(`http://localhost:4000/sleep/3000`); // 3秒の遅延

    if (!selectedId) {
        return (
            <Right />
        );
    }

    let note = fetch(`http://localhost:4000/notes/${selectedId}`).json();
    let {title, body, updated_at} = note;
    let text = `${selectedId}${title}${body}${updated_at}`;

    return (
        <Right text={text} />
    );
}

Webブラウザで確認してみましょう。
Nextを押すと、idに応じたデータが表示されるようになったと思います。

注:このままだと、存在しないidを指定した場合にシンタックスエラーになって落ちてしまうため、デモのAPIを修正(暫定対応)してください。

  • server/api.server.js(変更します)
    177行目、 res.json(rows[0]);res.json(rows[0] || "null"); に変更します。
app.get(
  '/notes/:id',
    ()
    res.json(rows[0] || "null"); // ←変更
    ()
);
  • "null" にした理由はこちらを参照ください。

https://www.rfc-editor.org/rfc/rfc8259

https://stackoverflow.com/questions/9158665/json-parse-fails-in-google-chrome
  • この件、reactjs/server-components-demo に Pull Request を出しました。

https://github.com/reactjs/server-components-demo/pull/50

fetchによるREST API処理

PostgreSQLにレコードを登録してみましょう。
デモで用意されているAPI( server/api.server.js に実装されています )を使います。
server/api.server.js には登録の他に、更新・削除のAPIも用意されています。

デモのコードを参考に登録処理を実装してみましょう。

新規登録(idは新しく付与)されます。Nextボタンを押して、新規作成されたデータを確認してみましょう。一番最後に追加されています。
onClickにトランジションを入れても大丈夫です。

  • src/Former.server.js(新規作成します)
import {fetch} from 'react-fetch';
import FormerClient from './Former.client';

export default function Former({selectedId}) {
    const note =
        selectedId != null
            ? fetch(`http://localhost:4000/notes/${selectedId}`).json()
            : null;

    if (!note) {
        return <FormerClient id={null} initialTitle={""} initialBody={""} />;
    }

    let {id, title, body} = note;

    return <FormerClient id={id} initialTitle={title} initialBody={body} />;

}
  • src/Former.client.js(新規作成します)
import {useState, useTransition} from 'react';
import {useLocation} from './LocationContext.client';
import {createFromReadableStream} from 'react-server-dom-webpack';
import {useRefresh} from './Cache.client';

export default function Former({id, initialTitle, initialBody}) {
    const [title, setTitle] = useState(initialTitle);
    const [body, setBody] = useState(initialBody);

    const [location, setLocation] = useLocation();
    const [, startNavigating] = useTransition();
    const refresh = useRefresh();

    function navigate(response) {
        const cacheKey = response.headers.get('X-Location');
        const nextLocation = JSON.parse(cacheKey);
        const seededResponse = createFromReadableStream(response.body);
        startNavigating(() => {
            refresh(cacheKey, seededResponse);
            setLocation(nextLocation);
        });
    }

    // 登録処理
    async function handleCreate() {
        const payload = {title, body};
        const requestedLocation = {
            selectedId: "",
            isEditing: false,
            searchText: location.searchText,
        };
        const endpoint = `http://localhost:4000/notes/`;
        const method = `POST`;
        const response = await fetch(
            `${endpoint}?location=${encodeURIComponent(JSON.stringify(requestedLocation))}`,
            {
                method,
                body: JSON.stringify(payload),
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
        console.log(response);
        navigate(response);
    }

    // 更新処理
    async function handleUpdate() {
        const payload = {title, body};
        const requestedLocation = {
            selectedId: location.selectedId,
            isEditing: false,
            searchText: location.searchText,
        };
        const endpoint = `http://localhost:4000/notes/${location.selectedId}`;
        const method = `PUT`;
        const response = await fetch(
            `${endpoint}?location=${encodeURIComponent(JSON.stringify(requestedLocation))}`,
            {
                method,
                body: JSON.stringify(payload),
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
        console.log(response);
        navigate(response);
    }

    // 削除処理
    async function handleDelete() {
        const payload = {title, body};
        const requestedLocation = {
            selectedId: location.selectedId,
            isEditing: false,
            searchText: location.searchText,
        };
        const endpoint = `http://localhost:4000/notes/${location.selectedId}`;
        const method = `DELETE`;
        const response = await fetch(
            `${endpoint}?location=${encodeURIComponent(JSON.stringify(requestedLocation))}`,
            {
                method,
                body: JSON.stringify(payload),
                headers: {
                    'Content-Type': 'application/json',
                },
            }
        );
        console.log(response);
        navigate(response);
    }

    return (
        <form onSubmit={(e) => e.preventDefault()}>
            <input
                type="text"
                value={title}
                onChange={(e) => {
                    setTitle(e.target.value);
                }}
            />
            <input
                type="text"
                value={body}
                onChange={(e) => {
                    setBody(e.target.value);
                }}
            />
            <button
                onClick={() => {
                    handleCreate();
                }}>
                Create
            </button>
            <button
                onClick={() => {
                    handleUpdate();
                }}>
                Update id={location.selectedId}
            </button>
            <button
                onClick={() => {
                    handleDelete();
                }}>
                Delete id={location.selectedId}
            </button>
        </form>
    );
}
  • src/App.server.js(変更します)
    作成したFormer(サーバーコンポーネント)を記述します。

<Former /> の親となる要素には、keyを与えてください。keyは、どの要素が変更・追加・削除されたのかを、Reactが識別する際に必要となります。
下記では <section></section> を使いましたが、 <div></div> でも大丈夫です。

import {Suspense} from 'react';

import Hello from './Hello.server';
import World from './World.server';
import Right from "./Right.client";
import Former from "./Former.server";

export default function App({selectedId, isEditing, searchText}) {
    return (
        <div className="main">
            <Hello selectedId={selectedId} />
            <Suspense fallback={<Right text={"This is suspense."} />}>
                <World selectedId={selectedId} />
            </Suspense>

            <section key={selectedId}>
                <Former selectedId={selectedId} isEditing={isEditing} />
            </section>
        </div>
    );
}

外部のDBを利用する

credentials.js を変更します。

  • credentials.js

例:ec2-18-181-96-11.ap-northeast-1.compute.amazonaws.com のDBを利用する。

module.exports = {
  host: 'ec2-18-181-96-11.ap-northeast-1.compute.amazonaws.com',
  database: 'notesapi',
  user: 'notesadmin',
  password: 'password',
  port: '5432',
};

Webサーバー(express)のポートを変更する

80番にする例です。

server/api.server.js を80に変更します。

const PORT = 80;

Dockerを使用している場合、docker-compose.ymlの設定も80に変更します。

    ports:
      - '80:80'
    environment:
      PORT: 80

その他、REST APIを使用している箇所(エンドポイント)を80に変更します。

fetch(`http://localhost:80/notes/...`)

※80番なので、省略してもかまいません。

スケールアウトについて

簡単な検証を行ってみました。
結論から言うと一般的な方法でスケールアウトできます。

検証

React Server Components のデモを Amazon Linux2 (EC2) 3台にデプロイ。

※DB接続先を変更しています

module.exports = {
  host: 'ec2-18-181-96-11.ap-northeast-1.compute.amazonaws.com',
  database: 'notesapi',
  user: 'notesadmin',
  password: 'password',
  port: '5432',
};

※DB接続先を変更しています

module.exports = {
  host: 'ec2-18-181-96-11.ap-northeast-1.compute.amazonaws.com',
  database: 'notesapi',
  user: 'notesadmin',
  password: 'password',
  port: '5432',
};

次に Route 53 を使って、リクエストを振り分けるように設定します(DNSラウンドロビン)。

rsc-demo.cmsvr.live

レコードタイプ
A

値
52.192.75.244
54.238.209.222

これでアクセスしてみます。

期待通りの動作をすると思います。

これは通常のSSRのように、クライアントの状態をサーバーに送信しているためです。
具体的には、Appの引数にある下記の値を、URLのqueryとHeaderのX-Locationに設定して整合性を保っています。

{selectedId, isEditing, searchText}

ただし、デモにあるキャッシュの処理は工夫が必要かもしれません。

演習

React Server Components のWebアプリを作ってみましょう。
いきなり1から作ることは大変ですので、ちょっとしたWebアプリを作成しておきました。

エンジニアが気軽に React Server Components を試すことができ、それをすぐに公開できるWebアプリです。操作はとても簡単です。Fork して、デプロイ用の docker-compose.yml を上書きするだけです。

Dockerを使って、エフェメラルポート(ポート番号49152〜65535)を割り振り、個々のアプリを起動する仕組みです。チーム内のデモ用開発などのご利用にどうぞ。

const portDynamicMin = 49152;
const portDynamicMax = 65535;

課題

リファクタリングをしてみましょう。

  • body はデモコードのままの変数名です。 body ではなく、 port にしてみましょう。
  • title はデモコードのままの変数名です。title ではなく、 url にしてみましょう。
  • vteacher.cmsvr.live をあなたが取得しているドメインにしてみましょう。
  • サーバー側のレンダリング処理が Former.client.js を利用しているだけです。さらに、クライアントコンポーネントからfetchをする作りになっています。SPAらしい動きではありますが、現状の作りですと、React Server Components の恩恵を受けていない形なので、改善してみましょう。

☕️ポエム 1: SSR経験者ならReact Server ComponentsでSPAが簡単に作れる話

弊社が開発しているVTeacherの一部にReact Server Componentsを使用しています。YouTube等にライブ配信をするサービスです。

ぜひともサインイン(Googleアカウント)して、FilterやPIN留めの操作をしてみて、SPAらしい挙動を確認してください。余計なあたまからのレンダリングによる白い画面のチラツキがなく、PCにインストールするソフトウェアや、スマホのアプリのような操作体験ができるものです。

ソースコードは コチラ です。

※説明のため、ベースとした reactjs/server-components-demo のコードをできるだけ残し、必要に応じてコードを追加する形にしています(ファイルの先頭に //@ts-check を追加しています)。

重要なことは「SPA」感が出ていることです。

☕️ポエム 2: JavaScriptを巡る主導権争い

React Server Componetns と直接は関係ありませんが、47歳さんの漫画をみて、思うことがありました。昨今の「JavaScriptはバックエンドエンジニアのものか、フロントエンドエンジニアのものか?」というような、まるでDevOpsのような問題がJavaScriptでも起きています。

(C) tome_ura さん

https://twitter.com/tome_ura/status/1420228522929311745

https://twitter.com/tome_ura/status/1420587380235378690

これはCさんチーム(おそらくAPI担当?)とアプリチームの話だと思いますが、DevOpsのような争いが実際の職場でも発生していると思います。

Wikipedia
https://ja.wikipedia.org/wiki/DevOps
DevOps(デブオプス)は、ソフトウェア開発手法の一つ。開発 (Development) と運用 (Operations) を組み合わせたかばん語であり、開発担当者と運用担当者が連携して協力する(さらに両担当者の境目もあいまいにする)開発手法をさす。ソフトウェアを迅速にビルドおよびテストする文化と環境により、確実なリリースを、以前よりも迅速に高い頻度で可能とする組織体制の構築を目指している。

React Server Componetnsは、フロントエンドとバンクエンドで共通言語を利用できます。
フロントエンドとバックエンドのJavaScriptを巡る主導権争いも、解決できるかもしれません。

FacebookのLauren Tan(poteto)さんもRFC内でこれについて回答しています。

(翻訳)

アーキテクチャに関しては、チームが選択した言語でバックエンドサービスを作成し続けることも期待しています。フロントエンドチームは、React Server Componentsを採用することを選択した場合、それらのバックエンドサービスを構成するnodejsで記述されたフロントエンドAPI / BFF(フロントエンドのバックエンド)を作成できます。ここでの良い点は、これをすべて1つの言語と1つの技術スタックで実行できることです。

☕️ポエム 3: 今までのSPA

SPAについて、今までは下記のアーキテクチャが多かったのではないでしょうか。

  1. React + (Next) + API(REST/GraphQL)
  2. MVCフレームワーク(PHPやRuby on Rails等)のViewにReactやVue

2について、
5年くらい前でしたら「ReactはMVCのViewを補うライブラリ」程度の役割でしたが、昨今の進化を見る限りでは、Viewの一部では恩恵を受け切れていない状態になっていると思います。

1について、
1が最近のモダンなアーキテクチャ・スキルセットでしょう。しかしSSGにできるサイト/サービスには限りがあり、結局のところSSRの検討が必要になることが多いと思います。

さらに、これらはWaterfall問題が発生しやすく、その対策が必要になります(Facebookの中の人いわく、React + Relay + GraphQLでクライアント側のWaterfallはあっさり解決するのだが、Facebookのエコシステムを使っていない人は、React Server Components - demo の方法は効果的だと説明しています。※Suspense を使用します)。

☕️ポエム 4: ウォーターフォール(Waterfall)問題とは?

最初のコンポーネントのfetchが完了した後に、次のコンポーネントのfetchが開始するような コンポーネント設計上のアンチパターンです。※開発手法のWaterfallとは異なります(上から下に水が流れるイメージは同じです)。

具体的には下記のような例です。

<Hello /> がレンダリングを完了するまで、 <World /> はfetchを開始できない。

<Hello>
    <World />
</Hello>
// <Hello />
function Hello() {
    const result = fetchHello(...); // Helloに関するfetch処理
    return (
        ...
    );
}
// <World />
function World() {
    const result = fetchWorld(...); // Worldに関するfetch処理
    return (
        ...
    );
}

☕️ポエム 5: React Server Components は どの立ち位置か

そもそもReact Server Componentsとは何なのか?
React Server Componentsを少し勉強した人ほど、React Server Componentsの立ち位置がよくわからなくなってくると思います。

Facebookのフロントエンドエンジニアを知る

React Server Componentsの誕生の経緯を知るために、先にFacebookのことを知ってみましょう。これは米国カリフォルニア州メンローパーク(Menlo Park)の求人です。

https://www.facebook.com/careers/v2/jobs/398439901421999/

Facebookにおける、フロントエンドエンジニアとは?

(翻訳)

フロントエンドエンジニアの責任
1.多くのエンジニアが関与する複雑な技術的または製品的取り組みを主導する
2.同僚に技術的なガイダンスとメンターシップを提供する
3.ニュースフィードなどのFacebook製品の機能とユーザーインターフェイスを実装する
4.複雑なWebアプリケーションを駆動する効率的で再利用可能なフロントエンドシステムを設計する
5.プロダクトデザイナー、プロダクトマネージャー、ソフトウェアエンジニアと協力して、魅力的なユーザー向け製品を提供します
6.パフォーマンスとスケーラビリティの問題を特定して解決する

「React Server Components」の提案は、これらのうち4と6に該当すると思います。

☕️ポエム 6: React Server ComponentsのRFC

下記がReact Server ComponentsのRFCです。
( RFC = Request for Comments = 意見募集中 )

https://github.com/reactjs/rfcs/pull/188

ここでFacebookのDan Abramov(React Core)さんとLauren Tan(React Data)さんが回答をしています。この2人はdemoの動画でも話している2人です。

https://www.youtube.com/watch?v=TQQPAU21ZUw

重要なキーワードとして下記のことを挙げています。

  1. バンドルサイズゼロ
  2. (クライアント側の)Waterfall問題

バンドルサイズゼロとは

https://ja.reactjs.org/blog/2020/12/21/data-fetching-with-react-server-components.html

FacebookのLauren Tan(poteto)さんがRFC内でこれについて回答しています。

(翻訳)

バンドルサイズが大きいということは、ダウンロード、解析、実行するJavaScriptの数が多いということです。ローエンドのデバイスでは、この作業を迅速に行うための計算能力が低いため、これは大きな問題となります。

React Server Componentsをひとことで説明するなら 「パフォーマンス改善のための技術」 でしょう。

ですが、副産物的なメリットもあります。それを次に説明します。

☕️ポエム 7: React Server Componentsの副産物的なメリット

React Server Componentsは、SSR経験者にとってSPAをつくるときに「React Server Components」ですと、とても作りやすいと思います。
※ここでいうSSRとは、いわゆる広義のSSR(PHPやRuby on Railsなど)を指します。

Lauren Tan(poteto)さんが「状態管理」について回答しています。

https://github.com/reactjs/rfcs/pull/188#issuecomment-751283849

(翻訳)

状態管理をほとんど行う必要がありませんでした。したがって、多くの点で、React Server Componentsは、インタラクティブなクライアントコンポーネントからデータフェッチを分離するのに実際に役立つと思います。

いままではSPAを作る際、コンポーネントに分けたあと、fetchでサーバーからデータを取得し、コンポーネント同士でデータの整合性を保ちながら、状態を管理するなど、少し敷居が高かったのです。でもReact Server Componentsなら、まるでPHPやRoRを書くように、SPAを実装できると思います。

☕️ポエム 8: React Server Componentsの重要なキーワードを整理

  • バンドルサイズゼロを目指す
  • クライアント側のWaterfallを解消
  • コンポーネントの状態管理が楽

☕️ポエム 9: 基本はSSR(PHPやRails等)の考えでつくることができる

SSRは広義(PHPやRailsなど)の意味とします。
SSRの基本的な動作は、たとえばbuttonでclickイベントが発生したら、いったんサーバにリクエストをし、サーバーはそれに応じたhtmlを返すものです。原則、requestのたびにページ全体がレンダリング(描画)されます。

React Server Components は名称にサーバーが入っています。名前通りにバックエンド(と日本では定義している)の処理もできます。
React Server Componentsはフレームワークですが、CakePHPやRailsのようなフルスタックフレームワークではありません。都度、機能を実装、またはライブラリを使うことになります。

何度も言いますが、重要なポイントは「SSRの感覚で書いているのに、SPAらしく動作すること」です。

☕️ポエム 10: React Server Components 作り方の基本

今から作ろうとするWebアプリケーションに状態を持たせましょう。
聞きなじみのある言葉で説明するなら、オブジェクト指向で言うところのMutable設計するということです。

Immutable Wikipedia
イミュータブル (英: immutable) なオブジェクトとは、作成後にその状態を変えることのできないオブジェクトのことである。対義語はミュータブル (英: mutable) なオブジェクトで、作成後も状態を変えることができる。

https://ja.wikipedia.org/wiki/イミュータブル
React
https://ja.reactjs.org/tutorial/tutorial.html#detecting-changes

例:

classDiagram
    class App
    App : +String userId
    App : +String token
    App : +String lang

App.server.js

function App({userId, token, lang}) {
//...略
}

JavaScriptのonClickイベントで次のように記述すると、状態を変更できます。
これをReact Server Components では「Locationを変更する」と言います。

setLocation((loc) => ({
    userId: "1001",
    token: "abcdef",
    lang: "ja",
}));

onClickのイベント発生時に、アプリケーションの状態を変更(setLocation)すると、いったんサーバーにいきます(このあたりは差分だけレンダリングするなど、全部レンダリングしない仕組みになっています)。

これはまるでPHPやRoRなどの、SSRのようです。
イベントのシーケンスはSSRのものと同じイメージになります。

ちなみに、各引数には{}を忘れないようにしてください。propsを指定の変数名で展開してくれます。propsをまるごと受け取りたい、そして子供に渡したい場合は、{}なしでpropsなどの変数名で良いと思います。

function App(props) {
    console.log(props);
    console.log(`props.userId=${props.userId}`);
    console.log(`props.token=${props.token}`);
    console.log(`props.lang=${props.lang}`);
    
    return (<div></div>);
}
{
    userId: 1001
    token: abcdef
    lang: ja
}
props.userId=1001
props.token=abcdef
props.lang=ja

☕️ポエム 11: React Server Componentsの認証

認証について。

Firebaseを使った認証を考えます。
ただしあくまで認証のみの利用とします。
FirebaseのCloudストレージなどは、基本的にクライアント側での非同期処理となり(結果としてuseEffect=副作用の多用となり)、React Server Componentsのせっかくのサーバーサイド処理(RDBへのCreate/Read/Update/Delete)との整合性が取りづらくなり、書きづらくなります。
そのため、基本はFirebaseのAuthenticationのみを利用することにしましょう。ログイン後にuidとtoken(30分ごとにリフレッシュされる)を取得し、それらをRDBに保存します。

sequenceDiagram
    User->>Firebase: sign in
    activate Firebase
    Firebase-->>User: uid, token
    deactivate Firebase
    User-->>PostgreSQL: uid, token

このuidとtokenは、各fetchの際に認証で使用されます。

Authの使用例:

import Auth from "./Auth.client";
<Auth />

これによりAuth(Firebaseの認証)がはいります。
Authのコードです。Firebaseを使っているので必ずclient.js(クライアントコンポーネント)にしてください。

Authの実装例:

FirebaseのapiKey等は settings.js に記述しておきます。

export const firebase_config = {
    apiKey: "",
    projectId: "",
    authDomain: "",
    databaseURL: "",
};
import firebase from 'firebase';

export const useSignIn = () => {
    let provider = new firebase.auth.GoogleAuthProvider();
    firebase.auth().signInWithRedirect(provider);
};

export const useSignInPopup = () => {
    let provider = new firebase.auth.GoogleAuthProvider();
    firebase.auth().signInWithPopup(provider);
};

export const useSignOut = () => {
    firebase.auth().signOut();
};

export const useCurrentUser = () => {
    return firebase.auth().currentUser;
};

export const useFirebase = () => {
    return firebase;
}

if (firebase.apps.length == 0) {
    const firebase_config = require(__dirname + '/../settings');
    const firebaseConfig = firebase_config["firebase_config"];

    firebase.initializeApp(firebaseConfig);
}

ちなみにFirebaseのバージョンが古い例なので、余裕があれば最新版のFirebaseで実装してください。話題のSupabaseに挑戦するのもありです。

☕️ポエム 12: React Server Componentsの多言語対応

react-intl や react-i18next が有名ですが、ここはあえて、React Server Componentsの恩恵をうけるために、react-i18nextライクの自作関数を作成し、サーバー側で利用できる形にしてみます。

実装したものを、実際にさわってみてください。言語(🇺🇸🇯🇵🇹🇼🇰🇷)を切り替えられます。

https://studywithme.cmsvr.live/

一度国旗をクリックしたら、サイドバーで動画を選んでもその言語を選択しています。
選択された言語 ( lang ) は、Appの状態(location)に追加してあります。

使い方

  • 言語選択画面の表示(クライアントコンポーネント)
<Language />
  • 指定言語のメッセージを表示
{t("HOW_TO_ADD", lang)}

設定

export const language_resources = {
    en: {
        translation: {
            SIGN_IN_TEXT: 'Sign in - Google Accounts',
            SIGN_OUT_TEXT: '✋Sign out',
            HOW_TO_ADD: 'Click ➕, select the users you want to study with from the sidebar.',
            HOW_TO_WATCH: 'Play live streams at the same time!',
            START_TOGETHER: 'STUDY WITH ME!',
            HOW_TO_LIVE: 'You can stream STUDY WITH ME on VTeacher.',
            ABOUT_HASHTAG: '( Live streaming with #STUDYWITHME hashtag )',
            DOWNLOAD_APP: 'Download on App Store',
        },
    },
    ja: {
        translation: {
            SIGN_IN_TEXT: 'まずは Sign in - Google Accounts',
            SIGN_OUT_TEXT: '✋Sign out',
            HOW_TO_ADD: 'サイドバーから一緒に勉強したいユーザーを➕',
            HOW_TO_WATCH: 'ライブ配信を同時に再生しよう!',
            START_TOGETHER: 'STUDY WITH ME! いっしょに勉強しよう!',
            HOW_TO_LIVE: 'VTeacherでSTUDY WITH MEの配信ができます',
            ABOUT_HASHTAG: '( #STUDYWITHME のハッシュタグをつけて配信 )',
            DOWNLOAD_APP: 'App Storeでダウンロード',
        },
    },
    zh_cmn_Hant: {
        translation: {
            SIGN_IN_TEXT: 'Sign in - Google Accounts',
            SIGN_OUT_TEXT: '✋Sign out',
            HOW_TO_ADD: '單擊 ➕,從側邊欄中選擇要與之一起學習的用戶。',
            HOW_TO_WATCH: '同時播放直播!',
            START_TOGETHER: '跟我一起學習!',
            HOW_TO_LIVE: '您可以在 VTeacher 上直播 STUDY WITH ME。',
            ABOUT_HASHTAG: '(使用#STUDYWITHME 標籤進行直播)',
            DOWNLOAD_APP: '在 App Store 下載',
        },
    },
    ko: {
        translation: {
            SIGN_IN_TEXT: 'Sign in - Google Accounts',
            SIGN_OUT_TEXT: '✋Sign out',
            HOW_TO_ADD: '➕을 클릭하고 사이드바에서 함께 공부할 사용자를 선택합니다.',
            HOW_TO_WATCH: '동시에 라이브 스트림을 재생합니다!',
            START_TOGETHER: '저와 함께 공부하세요!',
            HOW_TO_LIVE: 'VTeacher에서 STUDY WITH ME를 스트리밍할 수 있습니다.',
            ABOUT_HASHTAG: '( #STUDYWITHME 해시태그와 함께 라이브 스트리밍 )',
            DOWNLOAD_APP: '앱 스토어에서 다운로드',
        },
    },
};

使用例のコード

サーバーコンポーネントで処理をしているので、言語用の設定ファイルの行数が多くても(サイズ容量が大きくても)、計算量が多くても、クライアントのパフォーマンスに考慮した形になっています(サーバーサイドとのトレードオフとも言える)。

※言語選択時にlocationを設定しています。リロード用にlocalStorageへ保存しています。

さいごに

いかがでしたでしょうか?
オリジナルのコンポーネントを作成し、データの登録・更新・削除までを行うことができました。
TIPSで説明したような、React Server Components のためと言われている、実験的な機能も体験してみました。

ブック(応援)

Zennのブックを用意しました。内容は本投稿を整理したものです。

https://zenn.dev/rgbkids/books/dccbf9d3c0b206
是非ともご購入を。

ブック以外で閲覧するには

情報の新鮮さを重視しています。投稿記事は定期的に削除しています。どうしても過去記事を読みたい場合は、VTeacherの「サブスク加入者のページ」からご閲覧ください。

https://apps.apple.com/app/vteacher/id1435002381
graph TB
    D[ リモートワーカーの地位を向上 ] --> C
    E[ エンジニアリングの本質を追求 ] --> C 
    C[ VTEACHER の MISSION ]

この記事に贈られたバッジ

Discussion

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