🛰️

【Next.js+App Router】React Cosmos + Brandiで快適プレビュー

2023/08/04に公開

こんにちは! sugitaniと申します。 NUNW株式会社というところでCTOをやっています。

本稿では現行プロダクトのApp Router移行調査で得られた知見のうち、

"Next.js開発でApp Router = React Server Component/RSCを利用している場合にもStorybookのようにコンポーネントカタログを使ってUI開発を行いたいが、StorybookはRSCに対応できていなので、かわりにReact Cosmosをつかったら良かった"

という内容と

"DIコンテナを活用して、バックエンドサーバ等に依存しなくてもコンポーネントカタログでページレベルのプレビューを行えるようにしたら快適だった。DIコンテナはBrandiがちょうど良かった"

という内容をご紹介します

React Cosmosとは?

「コンポーネントの表示方法」を書いたファイルを置くと、自動でリストアップしてくれて、そのまま表示したり動かしたりできるUIの開発支援ツールです。

実際にデモ画面をいじっていただくのが早いです
https://reactcosmos.org/demo
React Cosmosデモ

コンポーネントカタログと呼ばれる事が多くて Storybookが有名ですが、RSCに対応できていません。React Cosmosは Next.jsに 「レンダリング結果をください」というページを作り、それをCosmonのコンソールから取得するという仕組みなのでRSCに対応できています。

実演

実際に簡単なプロジェクトを作って導入してみましょう

プロジェクト作成

プロジェクトを作成します。

$ npx create-next-app@latest
✔ What is your project named? … cosmos-and-brandi  
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … No
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias? … No

以降は作成したプロジェクトのディレクトリで作業します(例では cosmos-and-brandi で作成)

cd cosmos-and-brandi

いろいろ邪魔なので、いったんsrc/app の中身はすべて削除します

rm -f src/app/*

シンプルな src/app/layout.tsx を作成します

import React from "react";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  );
}

React Cosmosの導入

https://github.com/react-cosmos/react-cosmos/blob/v6.0.0-beta.6/docs/getting-started/next.md を参考にReact Cosmosを導入します

必要なモジュールをインストールします

npm i -D react-cosmos@next react-cosmos-next@next

cosmos.config.jsonを作成してcosmosを表示させるURLを設定します

{
  "rendererUrl": {
    "dev": "http://localhost:3000/cosmos/<fixture>",
    "export": "/cosmos/<fixture>.html"
  }
}

package.jsoncosmos, cosmos-export のscriptを追加します

"scripts": {
  "cosmos": "cosmos --expose-imports",
  "cosmos-export": "cosmos-export --expose-imports"
}

src/app/cosmos/[fixture]/page.tsx を作成します

import { nextCosmosPage, nextCosmosStaticParams } from 'react-cosmos-next';
import * as cosmosImports from '../../../../cosmos.imports';

export const generateStaticParams = nextCosmosStaticParams(cosmosImports);

export default nextCosmosPage(cosmosImports);

cosmos.importsが無い、というエラーが出ますが、これはcosmos実行時に生成されます

src/Hello.fixture.tsxを作成します

export default <h1>Hello World!</h1>;

以下を実行します

npm run dev

↑は動かしたままにしつつ、別ターミナルで以下を実行します

npm run cosmos

http://localhost:5001/ にアクセスするとcosmosが表示されます

動作例

複雑なページまるごとプレビューしたい!

小さくても大きくてもコンポーネントは気安くプレビューしまくりながら開発したい物です。単純なコンポーネントならよいのですが、バックエンド等と通信をしてデータを表示する「ページ」レベルのコンポーネントをプレビューしたい場合は工夫が必要です。

方法はいろいろ考えられますが "今回はDIコンテナをつかってCosmos利用時のときだけデータ読み込み部分を差し替える" という方法をとります。

サンプルとして「何かしらのバックエンドから取れるメモ情報表示する」という処理をイメージしたアプリケーションを作ってみましょう。

最初はサーバコンポーネントだけで動くアプリを作り、DIコンテナを利用してCosmosから開いたときだけ独自のデータを読み込めるようにします。(Step1〜3)

次にクライアントコンポーネントも作成してみて、クライアントコンポーネントでも同様にCosmosから開いたときだけ独自のデータを読み込めるようにします(Step4〜6)

Step1: 仮組みで表示部分を作ってみる(サーバコンポーネント編)

src/Memo.ts にMemo関係のオブジェクトを定義します

export type Memo = {
  id: string;
  timestamp: Date;
  body: string;
};

export interface MemoRepository {
  list(): Promise<Memo[]>;
}

// 仮組み
export class MemoRepositoryImpl implements MemoRepository {
  private dummyData: Memo[] = [
    { id: "1", timestamp: new Date(), body: "これはダミーデータです" },
    { id: "2", timestamp: new Date(), body: "おはようございます" },
    { id: "3", timestamp: new Date(), body: "こんにちは" },
    { id: "4", timestamp: new Date(), body: "さようなら" },
  ];

  async list(): Promise<Memo[]> {
    return this.dummyData;
  }
}

次に簡単に表示してみます。 src/app/page.tsx

import { cache, Suspense, use } from "react";
import { Memo, MemoRepositoryImpl } from "@/Memo";

export default function Page() {
  return (
    <>
      <h2>メモ一覧</h2>
      <Suspense fallback={<div>loading</div>}>
        <MemoListLoader />
      </Suspense>
    </>
  );
}

const memoListFetch = cache(() => 
    new MemoRepositoryImpl().list()); //TODO ここをDIする

function MemoListLoader() {
  const memos = use(memoListFetch());
  return <MemoList memos={memos} />;
}

export function MemoList({ memos }: { memos: Memo[] }) {
  const listBody = () =>
    memos.map((memo) => {
      return (
        <li key={memo.id}>
          {memo.timestamp.toLocaleString()} - {memo.body}
        </li>
      );
    });

  return <ul>{listBody()}</ul>;
}

動かすと以下の結果になります http://localhost:3000
動作例

Step2: Brandiを導入する(サーバコンポーネント編)

仮組みでは読み込む部分で MemoRepositoryImpl を直接使っていました

const memoListFetch = cache(() => 
    new MemoRepositoryImpl().list()); //TODO ここをDIする

DIコンテナであるBrandiを使って、以下のように書けるようにします

const fetcher = cache(() =>
  container.get(TOKENS.MemoRepository).list()
);

手順は以下の通りです

npm install brandi server-only

server-onlyは利用必須ではないが、使った方が良いので導入。 (server-onlyとは)

src/app/container_server.tsx にコンテナ設定コードを書きます

import { MemoRepository, MemoRepositoryImpl } from "@/Memo";
import { Container, token } from "brandi";
import 'server-only'

export const TOKENS = {
  MemoRepository: token<MemoRepository>("MemoRepository"),
};

export const container = new Container();

container
  .bind(TOKENS.MemoRepository)
  .toInstance(MemoRepositoryImpl)
  .inContainerScope(); // 実行時に差し替えたいのでinSingletonScopeではない

src/app/page.tsx にcontainerを利用するようにした版を書きます

import { cache, Suspense, use } from "react";
import { Memo } from "@/Memo";
import { container, TOKENS } from "@/app/step2/container_server";

export default function Page() {
  return (
    <>
      <h2>メモ一覧</h2>
      <Suspense fallback={<div>loading</div>}>
        <MemoListLoader />
      </Suspense>
    </>
  );
}

const memoListFetch = cache(() => 
    container.get(TOKENS.MemoRepository).list()); //ここがBrandi利用になった

function MemoListLoader() {
  const memos = use(memoListFetch());
  return <MemoList memos={memos} />;
}

export function MemoList({ memos }: { memos: Memo[] }) {
  const listBody = () =>
    memos.map((memo) => {
      return (
        <li key={memo.id}>
          {memo.timestamp.toLocaleString()} - {memo.body}
        </li>
      );
    });

  return <ul>{listBody()}</ul>;
}

http://localhost:3000/ を開くとBrandi導入前と同じ結果が得られます。

Step3: Cosmosを通過したときだけMockを設定する(サーバコンポーネント編)

次に、普通に開いたときはデフォルトのDIコンテナを、Cosmosから開いたときは別実装をいれたDIコンテナを使えるようにします。Brandiにはコンテナのスナップショットを撮る&元に戻す機能があるので、これを利用してDIコンテナが毎回初期化されるようにします

src/app/container_server.tsxを変更します

import { MemoRepository, MemoRepositoryImpl } from "@/Memo";
import { Container, token } from "brandi";
import { ReactNode } from "react";
import 'server-only'

export const TOKENS = {
  MemoRepository: token<MemoRepository>("MemoRepository"),
};

export const container = new Container();

container
  .bind(TOKENS.MemoRepository)
  .toInstance(MemoRepositoryImpl)
  .inContainerScope();

container.capture?.();

export function ContainerReset({ children }: { children: ReactNode }) {
  try {
    container.restore?.();
  } catch (e) {
    //何もしない
  }

  return <>{children}</>;
}

src/app/layout.tsxを変更します

import React from "react";
import { ContainerReset } from "@/app/container_server";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <ContainerReset>{children}</ContainerReset>
      </body>
    </html>
  );
}

これにより、コンテナはrootからレンダリングされる限り、リセットされるようになりました。

次にReact cosmosの項目(fixture)を作成し、そこでは別実装のリポジトリを使うようにします。

前準備としてコンテナ設定変更を行うヘルパーコンポーネントをsrc/app/UseStub.tsxに作成します

import { ReactNode } from "react";
import { container, TOKENS } from "@/app/container_server";
import "server-only";
import { Memo, MemoRepository } from "@/Memo";

class StubMemoRepository implements MemoRepository {
  private dummyData: Memo[] = [
    {
      id: "1",
      timestamp: new Date(),
      body: "これはCosmos実行時に設定したサンプルデータです",
    },
    { id: "2", timestamp: new Date(), body: "サンプルデータ1" },
    { id: "3", timestamp: new Date(), body: "サンプルデータ2" },
    { id: "4", timestamp: new Date(), body: "サンプルデータ3" },
  ];

  async list(): Promise<Memo[]> {
    return this.dummyData;
  }
}

export function UseStub({ children }: { children: ReactNode }) {
  container
    .bind(TOKENS.MemoRepository)
    .toInstance(StubMemoRepository)
    .inContainerScope();

  return <>{children}</>;
}

そしてsrc/app/page.fixture.tsx を以下の内容で新規作成します

import Page from "./page";
import { UseStub } from "@/app/UseStub";

export default (
  <UseStub>
    <Page />
  </UseStub>
);

これで http://localhost:3000 では元々の内容が表示されますが、Cosmos経由で実行したときはUseStubで別実装のRepositoryがセットされてからPageが実行されるようになりました

Step4: クライアントコンポーネントも作ってみる

次にクライアントコンポーネントも同様にDIコンテナを使えるようにすることを目指します。

まずクライアントコンポーネントとして「メモを追記する = 入力フォームを作って、送信ボタンをおしたらPOSTする」をイメージした機能を仮組みしましょう

src/PostAPI.tsを作成します

"use client";

export interface PostAPI {
  post(body: string): Promise<void>;
}

// 仮組み
export class PostAPIImpl implements PostAPI {
  async post(body: string): Promise<void> {
    console.log("本当だったらどこかにpostするコード");
  }
}

src/app/MemoForm.tsxを作成します

"use client";

import { useRef } from "react";
import { PostAPIImpl } from "@/PostAPI";

export function MemoForm() {
  const textAreaRef = useRef<HTMLTextAreaElement>(null);

  const onSubmit = async () => {
    const api = new PostAPIImpl();

    if (textAreaRef.current?.value) {
      await api.post(textAreaRef.current.value);
    }
  };

  return (
    <>
      <p>メモを書く</p>
      <div>
        <textarea ref={textAreaRef} rows={5} cols={100} />
      </div>
      <button onClick={onSubmit}>送信</button>
    </>
  );
}

src/app/page.tsxを整えます

// ...
export function MemoList({ memos }: { memos: Memo[] }) {
  const listBody = () =>
    memos.map((memo) => {
      return (
        <li key={memo.id}>
          {memo.timestamp.toLocaleString()} - {memo.body}
        </li>
      );
    });

  return (
    <>
      <ul>{listBody()}</ul>
      <MemoForm />
    </>
  );
}

http://localhost:3000/ を開くと

の見た目になり、テキストを入れて送信をクリックすると 本当だったらどこかにpostするコード がコンソールに出力されます

Step5: クライアントコンポーネントもDIコンテナを利用する

container_server.tsxとは別に
クライアントコンポーネント向けのDIコンテナを src/container_client.tsx に作成します。

"use client";

import { Container, token } from "brandi";
import { PostAPI, PostAPIImpl } from "@/PostAPI";

export const TOKENS = {
  PostAPI: token<PostAPI>("PostAPI"),
};

export const container = new Container();

container.bind(TOKENS.PostAPI).toInstance(PostAPIImpl).inContainerScope();

container.capture?.();

src/app/MemoForm.tsxをDI利用に書き換えます

"use client";

import { useRef } from "react";
import { container, TOKENS } from "@/app/container_client";

export function MemoForm() {
  const textAreaRef = useRef<HTMLTextAreaElement>(null);

  const onSubmit = async () => {
    const api = container.get(TOKENS.PostAPI); //ここがDI利用に

    if (textAreaRef.current?.value) {
      await api.post(textAreaRef.current.value);
    }
  };

  return (
    <>
      <p>メモを書く</p>
      <div>
        <textarea ref={textAreaRef} rows={5} cols={100} />
      </div>
      <button onClick={onSubmit}>送信</button>
    </>
  );
}

Step6: Cosmosを通過したときだけMockを設定する(クライアントコンポーネント編)

サーバコンポーネント版と同様に、Cosmos経由で表示したときは別実装をいれたDIコンテナを使えるようにします。

src/app/UseStubClient.tsxを作成します。

"use client";

import { ReactNode } from "react";
import { container, TOKENS } from "@/app/container_client";
import { PostAPI } from "@/PostAPI";

class StubPostAPI implements PostAPI {
  async post(body: string): Promise<void> {
    console.log("ダミーなので呼んでも安全なコード");
  }
}

export function UseStubClient({ children }: { children: ReactNode }) {
  container.bind(TOKENS.PostAPI).toInstance(StubPostAPI).inContainerScope();

  return <>{children}</>;
}

src/app/UseStub.tsx(サーバコンポーネント側)も微調整します

//...
export function UseStub({ children }: { children: ReactNode }) {
  container
    .bind(TOKENS.MemoRepository)
    .toInstance(StubMemoRepository)
    .inContainerScope();

  return (
    <>
      <UseStubClient>{children}</UseStubClient> // ここでchildrenをUseStubClientで包む
    </>
  );
}

以上です。これでCosmos経由で開き、操作をした場合は ダミーなので呼んでも安全なコード と出力されるようになりました。

おわりに

本稿はCosmos+Brandi導入の紹介なので分かりやすさのために 「通信部分を実装 → ページの作成 → ダミーの作成 → Cosmosの導入」という流れで実装を行いましたが、Cosmos導入後は「ダミー作成 → Cosmos上でページを作成 → 通信部分を実装」とするのが良いでしょう。

また今回の実装は導入にフォーカスしたものなので、より使い勝手が良くなるように改造するのがよいでしょう。

たとえば全ての通信処理には必ずCosmos用のダミー実装も用意するようにし、decorator機能を使って必ずダミー実装が利用されるようにするとfixture用意の手間がぐっと減るでしょう。

サーバ側/クライアント側で両方使う実装が有る場合、brandiのコンテナ継承が便利です。

この記事がどなたかのお役に立てたら幸いです。

積極採用しています!

NUNW株式会社ではフロントエンドエンジニアを始め、バックエンドエンジニア(TypeScript)、Flutterエンジニア、スクラムマスターを募集しています。

もしご興味があれば、 以下をご覧くださいよろしくお願いします!

株式会社NUNW

Discussion