💡

Reactのベストプラクティス集 bulletproof-react

2024/07/01に公開

「bulletproof-react」からReactのベストプラクティスを勉強していく。

セットアップ

以下のコマンドを実行するだけで、手元で動かして確認することができる。

git clone https://github.com/alan2207/bulletproof-react.git
cd bulletproof-react
cp .env.example .env
yarn install

標準

コードを綺麗に保つツール

  • ESLint
  • Prettier
  • TypeScript
  • Husky
    • Gitフック(コミットやプッシュ等を行う前後に自動的に実行されるスクリプト)を管理するツール

絶対パスによるimport

下記のように設定することでimport { XXX } from '@/components/ui'というようにsrc/からの絶対パスでimportできる。
※ 設定しないとimport { XXX } from '../../components/ui'のように相対パスになる。

tsconfig.json
"compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }

命名規則

プロジェクト内のファイルやフォルダに命名規則を適用できる。

.eslint.cjs
'check-file/filename-naming-convention': [
  'error',
  {
    '**/*.{ts,tsx}': 'KEBAB_CASE',
  },
  {
    // ファイル名の中間の拡張子を無視して、
    // bable.config.js や smoke.spec.ts のようなファイル名をサポート
    ignoreMiddleExtensions: true,
  },
],
'check-file/folder-naming-convention': [
  'error',
  {
    // __tests__ 以外のすべてのフォルダ名はケバブケースでなければならない
    'src/**/!(__tests__)': 'KEBAB_CASE',
  },
],

構造

components``hooks``lib``types``utilsの共有コードはすべてのコードでimport可能、featuresappからのみimportする。
Viteを利用している場合、バレルを使用しないことが推奨されている。

ESLint
.eslint.cjs
'import/no-restricted-paths': [
    'error',
    {
    zones: [
        // Previous restrictions...

        // enforce unidirectional codebase:
        // e.g. src/app can import from src/features but not the other way around
        {
            target: './src/features',
            from: './src/app',
        },

        // e.g src/features and src/app can import from these shared modules but not the other way around
        {
            target: [
                './src/components',
                './src/hooks',
                './src/lib',
                './src/types',
                './src/utils',
            ],
            from: ['./src/features', './src/app'],
        },
    ],
    },
],
src/
│
├── app/                 # アプリケーションレイヤー
│   ├── routes/          # アプリケーションルート(ページ)
│   ├── index.tsx        # メインアプリケーションコンポーネント
│   └── main-provider    # アプリケーション全体をグローバルプロバイダーでラップする
│                          アプリケーションプロバイダー
│
├── assets/              # 画像、フォント等の静的ファイル
│
├── components/          # 共通コンポーネント
│
├── config/              # グローバルな設定、エクスポートされた環境変数等
│
├── features/            # 機能単位で分割されたコード
│
├── hooks/               # 共有カスタムフック
│
├── lib/                 # アプリケーション用に設定された再利用可能なライブラリ
│
├── stores/              # ストア(※)
│
├── testing/             # テストユーティリティとモック
│
├── types/               # 共有の型
│
└── utils/               # 共有ユーティリティ関数

※ サンプルコードには無いディレクトリ

components/

components/
|
├── errors     # エラー
|
├── layouts    # レイアウト
|
├── seo        # SEO設定
|
└── ui         # UI

features/

各機能のディレクトリ。機能ごとに固有のコードが含まれる。
共有コンポーネントと機能関連のコードの混在を防ぐ。
機能間でインポートしないことが推奨されている。

features/xxx/
|
├── api         # 特定の機能に関連するAPIリクエストとフック
|
├── assets      # 特定の機能でのみ使用する静的ファイル(※)
|
├── components  # 機能単位のコンポーネント
|
├── hooks       # 機能単位のフック(※)
|
├── stores      # 特定の機能のストア(※)
|
├── types       # 機能内で使用される型(※)
|
└── utils       # 機能内で使用されるユーティリティ関数(※)

※ サンプルコードには無いディレクトリ

コンポーネントとスタイル

コンポーネントのベストプラクティス

  • 使用場所のできるだけ近くに配置する
    • コンポーネント・関数・スタイル・状態など、使用されている場所のできるだけ近くに配置する。
    • それによりコードが読みやすくなるだけでなく、冗長な再レンダリングも減らすことができる。
  • ネストされたレンダリング関数を持つ大きなコンポーネントを避ける
    • アプリケーション内に複数のレンダリング関数を追加しない。
    • 別のコンポーネントに抽出する。
  • 一貫性を保つ
    • コードフォーマッターを利用してコードスタイルの一貫性を保つ。
  • コンポーネントが受け入れるプロパティの数を制限する
    • プロパティが多すぎる場合、複数のコンポーネントに分割するか、子またはスロットを介した構成を検討する。
  • 共有コンポーネントを抽象化しコンポーネントライブラリとしてまとめる
    • 共通のUIコンポーネントや機能を一つの統一されたコンポーネントとしてまとめる。
    • アプリケーションのニーズに合わせ、サードパーティのコンポーネントもラップする。

コンポーネントライブラリ

UIコンポーネントをゼロから構築するのではなく、既存の実績のあるコンポーネントライブラリを使用する。

フル機能のコンポーネントライブラリ

スタイル設定されたコンポーネントのライブラリ。

  • Chakra UI
  • AntD
  • MUI
  • Mantine

ヘッドレスコンポーネントライブラリ

スタイル設定されていないコンポーネントライブラリ。デザインシステムを実装する場合、フル機能のライブラリを調整するより、スタイルが設定慣れていないヘッドレスコンポーネントライブラリを使用するほうが簡単で優れたソリューションになる。

  • Radix UI(サンプルコードで使用しているのはこれ)
  • Headless UI
  • react-aria
  • Ark UI
  • Reakit

スタイリングソリューション

  • tailwind(サンプルコードで使用しているのはこれ)
  • vanilla-extract
  • Panda CSS
  • CSS modules
  • styled-components
  • emotion

Storybook

コンポーネントを個別に開発、テストするためのツール。アプリケーションで使用しているすべてのコンポーネントをカタログにできる。

APIレイヤー

APIクライアントの単一インスタンスを使用する

アプリケーションとAPIのやり取りをする際、APIの単一インスタンスを事前に構築しアプリケーション全体で再利用する。
axios``graphql-request``apollo-clientなどのライブラリを使用して単一のAPIクライアントインスタンスを作成できる。
サンプルコードで使用しているのはaxios

リクエスト宣言の定義とエクスポート

APIリクエストはコンポーネント内で宣言するのではなく、個別に定義してエクスポートする。

src/features/users/api/delete-user.ts
...
export const useDeleteUser = ({
  mutationConfig,
}: UseDeleteUserOptions = {}) => {
  const queryClient = useQueryClient();

  const { onSuccess, ...restConfig } = mutationConfig || {};

  return useMutation({
    onSuccess: (...args) => {
      queryClient.invalidateQueries({
        queryKey: getUsersQueryOptions().queryKey,
      });
      onSuccess?.(...args);
    },
    ...restConfig,
    mutationFn: deleteUser,
  });
};
src/features/users/components/delete-user.ts
export const DeleteUser = ({ id }: DeleteUserProps) => {
  ...
  const deleteUserMutation = useDeleteUser({
    mutationConfig: {
      onSuccess: () => {
        addNotification({
          type: 'success',
          title: 'User Deleted',
        });
      },
    },
  });
  ...
}

APIリクエストを一か所にまとめることで、クリーンで整理された状態を保てる。
APIリクエスト宣言は次の要素で構成される。

  • リクエストとレスポンスデータの型と検証スキーマ
  • APIクライアントインスタンスを使用してエンドポイントを呼び出すfetcher関数
  • react-quer``swr``apollo-client``urqlなどのライブラリ上に構築されたfetcher関数を使用して、データのフェッチとキャッシュのロジックを管理するフック
src/features/users/api/delete-user.ts
// リクエストの型
export type DeleteUserDTO = {
  ...
};

// fetcher関数
export const deleteUser = ({ userId }: DeleteUserDTO) => {
  ...
};

// フック
export const useDeleteUser = ({
  mutationConfig,
}: UseDeleteUserOptions = {}) => {
  ...
})

状態管理

全ての状態情報を一つのリポジトリに保存するのではなく、使用状況に応じて様々なカテゴリに分割することを検討する。状態を分割することで、状態管理プロセスを合理化し、アプリケーションの全体的な効率を高めることができる。

コンポーネントの状態

コンポーネントの状態は個々のコンポーネント固有のものであり、グローバルに共有すべきではない。必要に応じて子コンポーネントにプロパティとして渡す。
まずコンポーネント自体の内部で状態を定義し、アプリケーションの他の場所で必要な場合は、それを上位レベルに昇格することを検討する。コンポーネントの状態を管理するときは、次の Reactフックを使用できる。

  • useState
    • 独立したより単純な状態の場合
  • useReducer
    • 単一のアクションで複数の状態を更新したいような複雑な状態の場合

アプリケーションの状態

アプリケーションの状態は、グローバルモーダル、通知の制御、カラーモードの切り替えなど、アプリケーション全体に影響する部分を管理する。最適なパフォーマンスとメンテナンスの容易さを確保するため、状態はそれを必要とするコンポーネントの近くに配置する。効率的な状態管理のため、初めからすべての状態をグローバルにするのではなく、必要な場合のみグローバル化する。

適切なアプリケーション状態管理方法:

  • context + hooks
  • redux + redux toolkit(サンプルコードで使用しているのはこれ)
  • mobx
  • zustand
  • jotai
  • xstate

サーバーキャッシュの状態

サーバーキャッシュの状態とは、サーバーから取得し、後々使用するためにクライアント側でローカルに保存されるデータを指す。パフォーマンスを向上させ、データ取得プロセスを最適化するには、より効率的なキャッシュメカニズムを検討することが重要。

サーバーキャッシュライブラリ:

  • react-query - REST + GraphQL(サンプルコードで使用しているのはこれ)
  • swr - REST + GraphQL
  • apollo client - GraphQL
  • urql - GraphQl
  • RTK

フォームの状態

フォームの状態を処理するときは、Formik``React Hook Form``Final Formなどのライブラリを使用してプロセスを効率化することを検討する。これらのライブラリには、組み込みの検証、エラー処理、フォーム送信機能が用意されているため、アプリケーション内でフォームの状態を管理しやすくなる。

フォームソリューション:
-React Hook Form(サンプルコードで使用しているのはこれ)
-Formik
-React Final Form

また、前述のソリューションと統合して、クライアント側で入力バリデーションを書けることもできる。

検証ライブラリ:

  • zod(サンプルコードで使用しているのはこれ)
  • yup

※ サンプルコードではFormライブラリをラップし、検証ライブラリと統合してある。(/components.ui/form/form.tsx

URLの状態

URLの状態とは、ブラウザのアドレスバー内に保存され操作されるデータを指す。通常、URLパラメータ(/app/${dynamicParam})、もしくはクエリパラメータ(/app?dynamicParam=1)で管理される。react-router-domなどのルーティングソリューションを組み込むことで、URLの状態に効果的にアクセスし制御できるようになり、ブラウザのアドレスバーから直接アプリケーションパラメータを動的に操作できるようになる。

テスト

テストの種類

  • ユニットテスト
    • 最小のテスト。
    • アプリケーション全体で使用される共有コンポーネントや関数のテスト、単一のコンポーネント内の複雑なロジックなど、アプリケーションの個々の部分を個別にテストする。
  • 統合テスト
    • アプリケーションの様々な部分がどのように連携し動作するかをテストする。
    • 単体テストはクリアしても部分間の接続に欠陥がある場合、アプリが正しく機能するとは限らないため、統合テストに注力する。
    • アプリケーションがスムーズかつ一貫して動作することを保証するために不可欠。
  • E2E(エンドツーエンド)
  • アプリケーション全体のテスト。
  • フロントエンドとバックエンド両方を自動化し、システム全体が正しく動作するかを確認する。

推奨ツール

  • Vitest
  • Testing Library
  • Playwright
  • MSW

エラー処理

APIエラー

APIエラーを効果的に管理するため、インターセプター(APIリクエストやレスポンスを捕捉して処理する仕組み)を実装する。これを使うことで、次のような操作が可能となる

  • 通知トーストのトリガー
    • エラーが発生した場合、ユーザーにエラーを知らせる通知を表示する。
  • 権限のないユーザーのログアウト
    • ユーザーが適切な権限を持っていない場合、自動的にログアウトさせる。
  • トークンの更新
    • 必要に応じて、認証トークンを更新するリクエストを送信し、アプリケーションの操作を安全かつシームレスに保つ。

アプリ内エラー

Reactのエラー境界(Error Boundary)を利用し、アプリケーション内で発生するエラーを処理する。エラー境界とは、Reactコンポーネントの特定の部分で発生したエラーをキャッチし、そのエラーがアプリ全体に影響を及ぼさないようにする仕組み。
よりスムーズなUXを保証するため、次のように検討する。

  • 複数のエラー境界
    • アプリ全体に1つのエラー境界を設置するのではなく、各機能ごとに複数のエラー境界を設置する。
  • エラーのローカル管理
    • これにより、特定の部分でエラーが発生しても、アプリ全体が中断することなく、エラーを局所的に封じ込めて管理できる。

エラー追跡

運用中に発生するエラーはすべて追跡する必要がある。エラー追跡には、Sentry等のツールを使用することを推奨。
Sentry:発生したすべての問題を報告し、どのプラットフォームやブラウザでエラーが発生したかを確認できる。ソースコードのどこでエラーが発生したかを詳細に確認するには、Sentryにソースマップをアップロードする。

セキュリティ

認証

※ クライアント側での認証の管理も重要だが、リソースの保護のためサーバー上で強力なセキュリティ対策を実装することも重要。クライアント側での認証かUXを向上させ、サーバー側でのセキュリティ対策を補完する。
リソースの保護は以下の二つの主要なコンポーネントで構成される。

認証

認証はユーザーのIDを確認するプロセス。SPAでは、一般的にJSON Webトークン(JWT)を使用する。ユーザーがログインまたは登録すると、トークンを受け取り、これを使って認証されたリクエストを行う。トークンはヘッダー内やCookie経由で送信され、ユーザーのIDとアクセス権限が検証される。
アプリケーションの状態としてトークンを保存するのが最も安全だが、アプリケーションを更新するとトークンがリセットされ、ユーザーの認証ステータスが失われる可能性がある。
そのため、トークンはCookieあるいはlocalStorage/sessionStorageに保存する必要がある。

localStorageCookieの比較

:Cookieを使う場合、特にHttpOnly属性を使用すると、JavaScriptからのアクセスが制限され、セキュリティが強化されます。

  • localStorage
    • クロスサイトスクリプティング(XSS)攻撃のリスクがあり、トークンが盗まれる可能性がある。
  • Cookie
    • HttpOnly属性を使用すると、JavaScriptからアクセスできないため、セキュリティが強化される。
    • サンプルではAPIがHttpOnly属性を適用し、アプリケーションがクライアント側から Cookieにアクセスできないことを前提として、Cookie管理にjs-cookieを使用している。

※ クロスサイトスクリプティング(XSS)攻撃からの保護
アプリケーションをXSS攻撃から守るため、すべてのユーザー入力をアプリケーションに表示する前にサニタイズすることが重要。これにより、悪意のあるスクリプトがアプリケーションに注入されるリスクを軽減できる。

ユーザーデータの取り扱い

ユーザー情報はグローバルな状態として扱うべきである。react-query-authreact-queryライブラリを使用することで、ユーザー状態を処理することができる。また、react context + hooks、あるいはサードパーティの状態管理ライブラリを使うこともできる。

承認

承認とは、ユーザーが特定のリソースにアクセスする権限を持っているかを確認するプロセスである。

ロールベースのアクセス制御(RBAC)

RBACでは、特定のロールに基づいてアクセス権限を管理する。例えば、USERやADMINのロールに異なるレベルのアクセス権を割り当て、ユーザーのロールに応じてアクセスを制限する。

許可ベースのアクセス制御(PBAC)

PBACは、より細かいアクセス制御を提供する。特にリソースの所有者だけに特定の操作の実行を許可するなど、特定の基準に基づいてアクセス権限を細かく調整する必要があるシナリオで、より柔軟なソリューションを提供する。例:コメントの作成者だけがそのコメントを削除できるようにする。

パフォーマンス

コード分割

コード分割は、アプリケーションのJavaScriptコードを小さなファイルに分割し、必要なときに必要な部分だけを読み込むことで、アプリケーションの読み込み時間を最適化する手法。

理想的な実装

  • ルートレベルでのコード分割
    • 最初に必須のコードのみを読み込み、他の部分は必要に応じて遅延フェッチ(遅延読み込み)する。
  • 過度な分割の回避
    • コードを細かく分割しすぎると、リクエスト数が増えて逆にパフォーマンスが低下する可能性があるため避ける。
src/app/routes/index.tsx
import { QueryClient } from '@tanstack/react-query';
import { createBrowserRouter } from 'react-router-dom';

import { ProtectedRoute } from '@/lib/auth';

import { discussionLoader } from './app/discussions/discussion';
import { discussionsLoader } from './app/discussions/discussions';
import { AppRoot } from './app/root';
import { usersLoader } from './app/users';

export const createRouter = (queryClient: QueryClient) =>
  createBrowserRouter([
    {
      path: '/',
      lazy: async () => {
        const { LandingRoute } = await import('./landing');
        return { Component: LandingRoute };
      },
    },
    {
      path: '/auth/register',
      lazy: async () => {
        const { RegisterRoute } = await import('./auth/register');
        return { Component: RegisterRoute };
      },
    },
    ...
  ])

コンポーネントと状態の最適化

  • 複数の状態に分割
    • すべてを1つの状態にまとめるのではなく、使用される場所に応じて状態を分割し、不要な再レンダリングを防ぐ。
  • 状態のローカライズ
    • 状態をそれを使用するコンポーネントにできるだけ近づけることで、依存しないコンポーネントの再レンダリングを防ぐ。
  • 高価な計算の最適化
    • 高価な計算を伴う状態は、状態初期化関数を使用して、初期化時にのみ実行されるようにする。
    // 非効率的な方法:毎回再レンダリングされる
    const [state, setState] = React.useState(myExpensiveFn());
      
    // 効率的な方法:初期化時にのみ実行される
    const [state, setState] = React.useState(() => myExpensiveFn());
    
  • 状態管理ライブラリ
    • 一度に多くの要素を追跡する状態を必要とするアプリケーションを開発する場合、jotaiなどのアトミック更新をサポートするライブラリの使用を検討する。
  • React Contextの賢い使い方
    • React Contextは、テーマ設定やユーザーデータ、小さなローカル状態など、あまり頻繁に更新されないデータ(低速データ)を管理するのに適している。
    • 頻繁に更新されるデータ(中速/高速データ)を扱う場合には、use-context-selectorなど適切なライブラリを使うことを推奨。
    • React Contextは、プロパティのドリルダウンを避けるためによく使われるが、多くのシナリオでは状態をリフトアップするかコンポーネントを適切に構成することで解決できる。
    • コンテキストとグローバル状態を早々に使用しない。

子コンポーネントの最適化

  • JSXの子要素の使用
    • childrenpropは、親コンポーネントの再レンダリングに影響を受けないため、最適化の基本的かつ簡単な方法。
    // 最適化されていない例
    const App = () => <Counter />;
    
    const Counter = () => {
      const [count, setCount] = useState(0);
    
      return (
        <div>
          <button onClick={() => setCount((count) => count + 1)}>
            count is {count}
          </button>
          <PureComponent /> // "count" が更新されるたびに再レンダリングされる
        </div>
      );
    };
    
    const PureComponent = () => <p>Pure Component</p>;
    
    // 最適化された例
    const App = () => (
      <Counter>
        <PureComponent />
      </Counter>
    );
    
    const Counter = ({ children }) => {
      const [count, setCount] = useState(0);
    
      return (
        <div>
          <button onClick={() => setCount((count) => count + 1)}>
            count is {count}
          </button>
          {children} // "count" が更新されても再レンダリングされない
        </div>
      );
    };
    
    const PureComponent = () => <p>Pure Component</p>;
    

画像の最適化

  • 遅延読み込み
    • ビューポートにない画像を遅延読み込みすることで、初期読み込み時間を短縮する。
  • 最新の画像形式の使用
    • WEBPなどの形式を使用して、画像の読み込みを高速化する。
  • srcset属性の使用
    • クライアントの画面サイズに最適な画像を読み込むためsrcsetを使用する。

ウェブバイタル

Googleがウェブサイトのインデックスする際にウェブバイタルを考慮するようになったため、LighthouseとPagespeed Insightsのウェブバイタルスコアに注意する。

デプロイメント

最高の配信とパフォーマンスを実現するために、アプリケーションとそのアセットをCDN経由でデプロイして提供する。そのための適切なオプションは以下。

  • Vercel
  • Netlify
  • AWS
  • CloudFlare

Discussion