【Next.js+App Router】React Cosmos + Brandiで快適プレビュー
こんにちは! 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の開発支援ツールです。
コンポーネントカタログと呼ばれる事が多くて 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.json
にcosmos
, 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エンジニア、スクラムマスターを募集しています。
もしご興味があれば、 以下をご覧くださいよろしくお願いします!
Discussion