Design Doc for react-boilerplate-2022

2022/02/25に公開約19,700字

これは何?

React(Next.js)アプリケーションのテンプレートのための Design Doc

  • React(Next.js)アプリケーションのテンプレートとして実装したリポジトリ shimpeiws/react-boilerplate-2022 の設計についてのDesign Docです
    • SSR/ISRはせずnext exportしてStatic Fileを出力する構成です
    • API Routesを使っていますが、API接続コードをローカルで動作させるためのもので本番動作させるためのものではありません

Design Doc

  • 本ドキュメントは実装したリポジトリの構成、ライブラリの選定理由など設計についての背景を示すためのドキュメントという位置づけです
  • 「デザインドックで学ぶデザインドック」(https://www.flywheel.jp/topics/design-doc-of-design-doc/) のフォーマットを利用させてもらいました

Goal

  • React(Next.js)アプリケーションを書き始めるために必要なテンプレートとなること
  • 特に以下の状態を満たせることを重視します
    • 新規のReact(Next.js)アプリケーションの開発開始時に必要なセットアップが完了していること
    • アプリケーションの大きな設計の指針とその選択の背景が示されており、開発メンバーがスムーズに機能の実装にとりかかれること

Background

  • Next.jsはビルドツールや開発用サーバを内包しておりWebフロントエンドアプリケーションの開発基盤として十分な機能を有していますが、その上に成り立つアプリケーションの設計に関しては独自に考える必要があります
    • React(Next.js)アプリケーションの初期立ち上げ時に十分に構成を練りきれなかったために、開発時に十分な品質・開発スピードが達成できないことがままありました
  • 自分自身がhooksの登場以降Stateの管理に関して明確な設計指針が持てていない、という課題意識がありました
    • Stateの管理やレイヤリングに関してはよしこさんの「2020年に立ち上げた Web フロントエンド構成の振り返り」(https://zenn.dev/yoshiko/articles/32371c83e68cbe) の記事に強く影響を受けています
  • 利用ライブラリについては本番環境に投入可能なものかを調査し実際に動作させたうえで導入を検討しました

Overview

詳細な設計については以下に分けた構成になっています

  • Stacks
    • ReactやNext.jsなど主な利用ライブラリ
  • State
    • Storeの構成に関しての考え方と利用ライブラリ
  • Layering
    • レイヤー構成とそれを実現するためのフォルダ構成
  • Styling & Component Guide
    • CSSのスタイリング
    • Atomic Designなどコンポーネント設計に関してはこのリポジトリとDesign Docの対象外
    • Storybookでのコンポーネントガイド作成
  • Form & Validation
    • フォーム作成とバリデーションの構成と利用ライブラリ
  • Testing
    • ユニットテストの範囲と利用ライブラリ
  • Lint
    • Lintツールの設定と適用
  • Others
    • その他のトピック

Detailed Design

実際のコードは https://github.com/shimpeiws/react-boilerplate-2022 を参照してください

Stacks

主な利用ライブラリは以下です。
基盤となるライブラリになるので、以降の章でバージョンの選定とバージョンアップが必要なタイミングについてまとめています。

State

ColocationとLifting State Up

Reactでの状態管理の考え方はhooksの登場以降大きな転換があったと思います。
React公式にも記載のあるColocationLifting State Upは特に重要な考え方だととらえています。

その変遷についてのまとめは別記事とすることとして、ここでは本リポジトリの状態管理の方針として以下を設定します。

  • 描画を行うコンポーネントとそのデータ取得などのロジックはなるべく近くに配置する
  • その上で複数のコンポーネントで同様のデータが必要な場合にはそのStateを上位の階層に移動するなど対応を考える

Store管理の変遷については以下に別記事として書きました。

https://zenn.dev/shimpeiws/articles/afcc43990d13c0

3種類のState構成

状態管理のためにStateを3種類に分類して実装します。

  • APIキャッシュ
  • Global State
  • コンポーネントローカルのState

それぞれに対応するライブラリとして以下を導入しています。

APIキャッシュ

APIサーバーにアクセスし、取得した結果を保持します。

状態管理の大方針として本リポジトリではColocationの考え方を採用しているので、実際に描画を行うコンポーネントから直接API呼び出しを行う処理(hooks)を呼び出します。

コンポーネントの配置がAPI呼び出しに直結する構成となるため、クライアントのパフォーマンス・サーバーへの負荷を考慮し、APIレスポンスをキャッシュする機構は必須であると考えSWRを導入しています。


Itemの一覧を取得する際のコード例が以下です。

Item一覧コード

src/components/ItemIndex/index.tsx

  • useItemListのhooksからItem一覧のデータとエラー状態を取得しています
  • src/pages 配下ではなく src/components 配下のコンポーネントから呼び出しを行う構成になっているのがColocationの考え方の反映です
import { ReactElement } from "react";
import { useItemList } from "../../usecases/Item/useItemList";
import { ItemList } from "./ItemList";

export const ItemIndex = (): ReactElement => {
  const { data, error } = useItemList();

  if (error) {
    return <>error</>;
  }

  if (!data) {
    return <>loading</>;
  }

  return (
    <>
      <ItemList items={data} />
    </>
  );
};

src/usecases/Item/useItemList.ts

  • custom hooksとして実装
  • returnとしてuseSWRの返りをそのまま返しています
  • fetcherにあたる処理はrepositories層で実装します
import useSWR from "swr";
import { Item } from "../../models/Item";
import { useItemRepository } from "../../repositories/ItemRepository";
import { generateItemIndexKey } from "./itemCacheKeyGenerator";

export const useItemList = () => {
  const itemRepository = useItemRepository();

  return useSWR<Item[]>(generateItemIndexKey(), () =>
    itemRepository.getItemList()
  );
};

src/repositories/ItemRepository.ts

  • 実際にAPIアクセスを行うfetcherの処理です
import { Item } from "../models/Item";

type ItemListResponseObject = {
  id: number;
  name: string;
};

export const useItemRepository = () => {
  const getItemList = async (): Promise<Item[]> => {
    const res = await fetch("/api/item");
    if (!res.ok) {
      throw new Error(res.statusText);
      return;
    }
    const resData = (await res.json()) as ItemListResponseObject[];
    return resData.map((item) => {
      return {
        id: item.id,
        name: item.name,
      } as Item;
    });
  };

  // 中略

  return {
    getItemList,
  };
};
Global State

APIレスポンスのキャッシュ以外で複数画面に渡り共有される状態を保持します。

実装にはRecoilを利用し、1つの関心事に対して1つのStateを作成することとします。


本リポジトリではヘッダーに表示するユーザー情報(名前・権限)を表示する部分で実装されています。

実際にはユーザー情報はAPIから取得するためAPIキャッシュとなることが予想されます。サンプル実装として参照してください

Global State コード

src/components/header/index.tsx

  • useUserStateでGlobal StateであるuserStateを取得しています
import { ReactElement } from "react";
import { useUserState } from "../../globalStates/User";
import { HeaderContents } from "./HeaderContents";

export const Header = (): ReactElement => {
  const userState = useUserState();
  const { user } = userState;

  return (
    <>
      <HeaderContents user={user} />
    </>
  );
};

src/pages/mypage/index.tsx

  • useUserStateとしてStateをexportしています
  • useUserMutatorsとしてStateの更新関数をexportしています
import { atom, useRecoilValue, useSetRecoilState } from "recoil";
import { useCallback } from "react";
import { User } from "../models/User";

type UserState = {
  user: User | undefined;
  language: Languages;
};

const Languages = {
  JAPANESE: "japanese",
  ENGLISGH: "english",
} as const;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type Languages = typeof Languages[keyof typeof Languages];

const userState = atom<UserState>({
  key: "user",
  default: {
    user: undefined,
    language: Languages.ENGLISGH,
  },
});

export const useUserState = () => {
  return useRecoilValue(userState);
};

export const useUserMutators = () => {
  const setState = useSetRecoilState(userState);

  const setUser = useCallback(
    (user: User) =>
      setState((previousState: UserState) => {
        return {
          ...previousState,
          user,
        };
      }),
    [setState]
  );

  // 中略

  return { setUser, setLanguage };
};
コンポーネントローカルのState

コンポーネント内に閉じて利用されるStateに関してはReact.useStateを利用して実装します。


本リポジトリではユーザー情報更新のフォームで入力値を保持する部分で実装しています。

フォームなのでreact-hook-formに置き換えるべき箇所ですが、useStateの例として残しています

useState コード

src/components/ItemForm/index.tsx

import React from "react";
import { Header } from "../../components/header";
import { useUserState, useUserMutators } from "../../globalStates/User";
import { UserRole, roleName } from "../../models/User";

export default function Index() {
  const userState = useUserState();
  const userMutators = useUserMutators();
  const { user } = userState;
  const [name, setName] = React.useState<string | undefined>(undefined);
  const [role, setRole] = React.useState<UserRole | undefined>(undefined);

  React.useEffect(() => {
    setName(user?.name);
    setRole(user?.role);
  }, [setName, setRole, user?.name, user?.role]);

  const handleSubmit = () => {
    const { setUser } = userMutators;
    const updated = {
      ...user,
      name,
      role,
    };
    setUser(updated);
  };

  return (
    <div>
      <Header />
      <div>
        <p>Name</p>
        <input
          value={name}
          onChange={(e) => {
            setName(e.target.value);
          }}
        />
      </div>
      <div>
        <p>Role</p>
        <select
          onChange={(e) => {
            setRole(e.target.value as UserRole);
          }}
        >
          <option value={UserRole.ADMIN} selected={role === UserRole.ADMIN}>
            {roleName(UserRole.ADMIN)}
          </option>
          <option value={UserRole.MEMBER} selected={role === UserRole.MEMBER}>
            {roleName(UserRole.MEMBER)}
          </option>
        </select>
      </div>
      <br />
      <div>
        <button onClick={handleSubmit}>Update</button>
      </div>
    </div>
  );
}

Layering

本リポジトリのsrc配下のフォルダ構成は以下です

% tree -L 1
.
├── components
├── globalStates
├── lib
├── models
├── next-env.d.ts
├── out
├── pages
├── repositories
├── tsconfig.json
└── usecases

8 directories, 2 files

アプリケーション実装に関わるのが以下フォルダです

  • components
    • 個別のコンポーネントの実装
  • globalStates
    • 別記参照
  • lib
    • 共通的に使う関数群
    • 日付操作やStorage操作など抽象度が高いものに限定する
  • models
    • components/usecases/repositoriesで利用される共通の型とその型に関わる関数
  • pages
    • Next.jsでのルーティングを受ける頂点のコンポーネント
    • componentsからの呼び出しとレイアウト調整のみを行い
  • repositories
    • APIアクセスなど外部インタフェースの実装
  • usecases
    • Stateの書き込み/読み出し
    • repositories層経由でAPIなど外部データアクセスを行う

各レイヤーの呼び出し順

各レイヤーの呼び出し順は以下です。

pages -> components -> usecases -> repositories

  • pagesは極力薄くためつため、直接usecasesを呼び出しません
  • usecases同士に依存を持たせないため、usecases同士での参照は行いません
  • modelsはcomponents/usecases/repositoriesの全てのレイヤーから呼び出されます

Styling & Component Guide

Next.jsで標準でサポートされているCSS Modules、Sassを採用し、Storybookでコンポーネントガイドを作成しています

各コンポーネント配下は以下のような構成です。

% tree

.
├── index.stories.tsx
├── index.tsx
└── text.module.scss

0 directories, 3 files

Storybook

Storybookで管理しやすいコンポーネント作成のために各コンポーネント配下はusecasesに接続されるコンポーネントと、Propsをrenderするだけのコンポーネントに切り分けをしています。

src/components/ItemDetail 配下

% tree -L 2
.
├── ItemDetailContents
│   └── index.tsx
└── index.tsx

1 directory, 2 files
Storybookコード

src/components/ItemDetail/index.tsx -> usecasesと接続される

import { useItemDetail } from "../../usecases/Item/useItemDetail";
import { ItemDetailContents } from "./ItemDetailContents";

export type ItemDetailProps = {
  id: number;
};

export const ItemDetail = (props: ItemDetailProps) => {
  const { id } = props;
  const { data, error } = useItemDetail({ id });

  if (error) {
    return <>error</>;
  }

  if (!data) {
    return <>loading</>;
  }

  return (
    <>
      <ItemDetailContents item={data} />
    </>
  );
};

src/components/ItemDetail/ItemDetailContents/index.tsx -> usecasesと接続されずpropsからのみrenderされる

import { ReactElement } from "react";
import { Item } from "../../../models/Item";

export type ItemDetailContentsProps = {
  item: Item;
};

export const ItemDetailContents = (
  props: ItemDetailContentsProps
): ReactElement => {
  const { item } = props;
  return (
    <>
      <p>Item Detail {item.id}</p>
      <p>id | {item.id}</p>
      <p>name | {item.name}</p>
      <p>description | {item.description}</p>
    </>
  );
};

Form & Validation

利用ライブラリは以下です。

Form コード

src/components/ItemForm/index.tsx

import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/router";
import { ReactElement } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { useItemCreate } from "../../usecases/Item/useItemCreate";

const schema = z.object({
  name: z.string().nonempty({ message: "Can't be empty" }),
  description: z.string().nonempty({ message: "Can't be empty" }),
});

export const ItemForm = (): ReactElement => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(schema),
  });
  const { createItem, errors: itemCreateErrors } = useItemCreate();
  const router = useRouter();

  const onSubmit = async (data: z.infer<typeof schema>) => {
    await createItem({
      name: data.name,
      description: data.description,
    });
    router.push("/item");
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <p>Item/New</p>
      {itemCreateErrors.length > 0 &&
        itemCreateErrors.map((error, i) => {
          return <p key={i}>{error}</p>;
        })}
      <div>
        <p>name</p>
        <input data-testid="input-name" {...register("name")} />
        {errors.name && <p>{errors.name.message}</p>}
      </div>
      <div>
        <p>description</p>
        <input data-testid="input-description" {...register("description")} />
        {errors.description && <p>{errors.description.message}</p>}
      </div>
      <input data-testid="submit-button" type="submit" />
    </form>
  );
};

Testing

Test対象・範囲の指針

ユーザーから見た振る舞いをテストするIntegration Testを最重要視し、独立性の高い部分に対してUnit Testを書く ことを大方針とします。

本リポジトリが扱う対象が立ち上げ初期のフェーズであることを考慮し、ユーザーから見た画面および操作の品質をなるべく少ない手数で担保できることを重視するためです。

E2Eやビジュアルリグレッションテスト、コンポーネントのスナップショットテストなどはプロダクトのフェーズに合わせて随時追加可能だと考えています。

利用ライブラリ

テストランナーとしてjest、アサーションなどのテスティングライブラリとしてReact Testing Library、HTTP通信のmockとしてMock Service Worker(msw)を採用しています

  • jest
  • React Testing Library
  • Mock Service Worker

テストコード

Integration Testはpages層のコンポーネントを対象に書きます。

Itemの新規登録フォームのテストが以下です。

Test コード

__tests__/pages/item/new.tsx

import { render, fireEvent, act, waitFor } from "@testing-library/react";
import { rest } from "msw";
import { setupServer } from "msw/node";
import New from "../../../src/pages/item/new";
import { ComponentWrapper } from "../../util/ComponentWrapper";

const mockServerWithSuccess = setupServer();
const mockPush = jest.fn();
jest.mock("next/router", () => ({
  useRouter() {
    return {
      push: mockPush,
    };
  },
}));

describe("Item New", () => {
  beforeAll(() => mockServerWithSuccess.listen());
  afterEach(() => {
    mockServerWithSuccess.resetHandlers();
    jest.clearAllMocks();
  });
  afterAll(() => mockServerWithSuccess.close());

  test("POST new item", async () => {
    mockServerWithSuccess.use(
      rest.post("/api/item/new", (req, res, ctx) => {
        return res(ctx.status(200), ctx.json({}));
      })
    );

    const { findByTestId } = render(
      <ComponentWrapper>
        <New />
      </ComponentWrapper>
    );

    await act(async () => {
      // Input Form
      const inputNameElement = await findByTestId("input-name");
      const inputDescriptionElement = await findByTestId("input-description");
      fireEvent.change(inputNameElement, { target: { value: "foo" } });
      fireEvent.change(inputDescriptionElement, { target: { value: "bar" } });

      // Click Submit Form
      const submitButtonElement = await findByTestId("submit-button");
      fireEvent.submit(submitButtonElement);
    });

    await waitFor(() => {
      expect(mockPush).toHaveBeenCalledWith("/item");
    });
  });

  test("POST new item with API Failer", async () => {
    mockServerWithSuccess.use(
      rest.post("/api/item/new", (req, res, ctx) => {
        return res(ctx.status(500), ctx.json({}));
      })
    );

    const { findByTestId, findByText } = render(
      <ComponentWrapper>
        <New />
      </ComponentWrapper>
    );

    await act(async () => {
      // Input Form
      const inputNameElement = await findByTestId("input-name");
      const inputDescriptionElement = await findByTestId("input-description");
      fireEvent.change(inputNameElement, { target: { value: "foo" } });
      fireEvent.change(inputDescriptionElement, { target: { value: "bar" } });

      // Click Submit Form
      const submitButtonElement = await findByTestId("submit-button");
      fireEvent.submit(submitButtonElement);
    });

    await waitFor(async () => {
      expect(mockPush).not.toHaveBeenCalled();
      expect(await findByText("Failed when post item")).toBeInTheDocument();
    });
  });
});

Lint

コードのフォーマットとしてESLintとPrettierを導入しています。それぞれの設定は最低限にとどめているので、プロジェクト内でルールの更新をかける想定です。

  • ESLint
  • Prettier

Others

Error Boundary

Error Boundary を実装しています。

実装に必要なgetDerivedStateFromError、componentDidCatch関数がClassコンポーネントでのみ利用可能な状態なのでClassコンポーネントでの実装としています。

bvaughn/react-error-boundary などhooksで提供されたものも検討しましたが、公式に従うことを優先しました。

Error Boundary コード

src/components/ErrorBoundary/index.tsx

import React from "react";

type ErrorBoundaryState = {
  hasError: boolean;
};

export class ErrorBoundary extends React.Component<
  unknown,
  ErrorBoundaryState
> {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    // TODO: Send error to tracking tool
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

Caveats

  • src/components 配下の構成については対象外としたため、componentの粒度・構成については別途検討する必要があります
  • テスト対象については最小限の範囲にとどめているため、プロダクトのフェーズにあわせた対象・方針の見直しが必要です

Alternatives

State

Context/Reduxの利用

3種類のStateのうち、Recoilで実装したGlobal Stateに関してはhooks(React.createContext/Context.ConsumerとReact.useReducerの併用)やReduxで代替可能です。

hooksに関しては追加ライブラリなしで実装できることから有力な選択肢だと考えます。

  • useReducerとの併用まで含めるとReduxに近い程度までコード量が膨らむ
  • Contextを接続したコンポーネントで再レンダリングが発生するため、パフォーマンスについても考慮した実装が必要になる

Reduxは内部でメモ化が行われる + reselectも併用するとパフォーマンスについてはContextよりも実装者が意識する範囲は減ります。

Redux Toolkitを導入すると定形のコードも整理され、比較的ストレスなく実装できます。

しかし今回Stateを3種類に分け、APIキャッシュをSWRの利用による実装とした結果、Global Stateをは小さい範囲に限定することができました。

Global Stateに求めるのはグローバルなデータの参照/更新というミニマムな要件に絞れたため、Recoilを選定しました。

Styling & Component Guide

CSS in JSの利用

styled-componentsやemotionなどのCSS in JSを本リポジトリでは採用せず、CSS ModulesとSassを採用しました。

Next.jsの組み込みで利用でき、必要十分にスタイリング可能と判断したのが最大の理由です。加えて今回対象外としたSSRを行う場合、styled-componentsなどの利用にはビルドの変更が必要になりそう、というのも理由の一つでした。

CSS in JS全般のメリットとしてCSSにローカルスコープを付与できる事があげられます。この点がスタイリング上のメリットとしてプロジェクト内で捉えられる場合、導入を検討すると良いと考えます。

Appendix

参考にした記事

2020年に立ち上げたWebフロントエンド構成の振り返り

https://zenn.dev/yoshiko/articles/32371c83e68cbe

「3種類で管理するReactのState戦略

https://zenn.dev/yoshiko/articles/607ec0c9b0408d

State Colocation will make your React app faster

https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster

フロントエンドでTDDを実践する(理論編)

https://qiita.com/taneba/items/48db2ad9cf10ad644908

ESLint, Prettier, VS Code, npm scripts の設定: 2021春

https://zenn.dev/teppeis/articles/2021-02-eslint-prettier-vscode

Node.js のバージョン

基本的に最新の Active ステータスの偶数バージョンを利用する

現状でいえば v16を利用する、2022 10 月以降から以降に v18 への以降を検討する必要がある。

https://nodejs.org/ja/about/releases/

node.js version

Next.js のバージョン

releaseはcanaryやpre releaseなど細かく管理されており、細かなバージョンは頻繁にリリースされる

メジャーアップデートに関しては直近半年に一度程度

React のバージョン

Discussion

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