はじめに
この記事は、Alan Alickovicさんの著書「React Application Architecture for Production」をまとめたものになります。Alanさんと言えばZennで最も人気のある記事「bulletproof-react」の作者であり、彼のprojectから学ぶことはとても多い印象です。
今回紹介する本は2023年1月に公開されたため、bulletproof-react以後のReactアプリケーションにおけるベストプラクティスの宝庫となっています。また、本で扱われているアプリケーションのProjectがGitHubで公開されていることから、Projectを眺めるだけでも勉強になる点があるかと思います。
想定読者
- Reactのアーキテクチャを模索している方
- テスト手法やCI/CDなどのアプリケーション設計に関心がある方
使用される技術と本の構成
- 言語
- TypeScript 4.8
- UIライブラリ
- React 18
- フレームワーク
- Next.js 12
- Global State管理
- Zustand
- Server State管理
- React Query
- Form State管理
- React Hook Form
- Styling
- Chakra UI
- Testing
- Jest
- React Testing Library
- Cypress
- UI Testing
- Storybook
本の構成は以下のようになっています。
- Reactアプリケーションを理解する
- Projectの構成
- 汎用Componentを実装する
- Page、Routingを実装する
- APIをモックする
- APIと繋ぎこみする
- 通知機能を実装する
- テスト
- CI/CD
ご覧いただいて分かる通り、順に読んでいくだけで一つのアプリケーションを実装する流れが網羅されています!そこで今回は各章において学びになったことをまとめながら、Reactアプリケーション開発に役に立つ知識を共有したいと考えています。
🍏 1. Reactアプリケーションを理解する
第1章はReactについての解説です。フロントエンドのUIライブラリにReactを採用する際に考えるべきことをまとめてくれています。
Reactのメリットは柔軟性であり、その柔軟性はデメリットにもなりうる
Reactは高価なエコシステムと柔軟性を持つが故に、コストが高まってしまうトレードオフがあります。また、Reactアプリケーションを構築する際にまず考えるべきことは以下のとおりです。
Projectの構造はどうする?
Reactはとても柔軟で小さなAPIを提供することから、Projectの構造についても1つの決まった考えがありません。ReactメンテナーのDan Abramovさんも“Move files around until it feels right(いいと思うまでファイルを移動させろ)”と言っていることから、そのProjectの構造はアプリケーションの要件などによって柔軟に変えていく必要があります。
Stateはどのように管理する?
ReactはState管理の方法としてContext APIを提供してくれます。しかし、複雑なアプリケーションになるとそれだけでは不十分なため適切なライブラリを選定する必要があります。そしてその選定基準はアプリケーションの要件と管理するべきStateの内容に依存します。
Stateが頻繁に更新される場合
その場合はRecoilやJotaiといったatom-basedなライブラリを採用するのがいいでしょう。
異なる多くのStateをComponent間で共有する必要がある場合
その場合は、ReduxやRedux Toolkitがいいでしょう。
上記のどちらでもない場合
もしも多くのStateをComponent間で共有する必要性やStateの頻繁な更新がない場合、ZustandやReact Contextを採用するのがいいでしょう。
このようにState管理ライブラリにもそれぞれ特徴があるため、アプリケーションの要件によって適切に選定することが必要です。
Stylingはどのように行う?
ReactのStylingにもいくつか種類があります(vanilla CSSやutility-firstなTailwind、CSS in JSなど)。Styleに関するライブラリの選定基準は以下のとおりです。
- 頻繁に再レンダリングされる可能性
- パフォーマンスの重要性
もし頻繁に再レンダリングされる可能性があり、パフォーマンスを意識する必要がある場合はbuild時にCSSを解決してくれるTailwindやvanilla CSSがいいでしょう。もしそれらの要件がない場合は、Styled ComponentsやEmotionのようなライブラリを採用することも可能です。
レンダリング戦略
今回実装するアプリケーション要件では、以下のページが存在します。
- 誰もが閲覧可能なページ
- Server-side rendering(SSR)
- 管理者用のページ
- Client-side rendering(CSR)
誰もが閲覧可能なページは高いパフォーマンスとSEOが求められます。その一方、管理者用のページはSEOに関して気にする必要はありません。以上の条件を踏まえて、誰もが閲覧可能なページにはServer-side rendering(SSR) を、管理者用ページにはClient-side rendering(CSR) を採用することにします。このように、ページごとの要件に合わせてレンダリングの戦略を変えることも必要です。
📒 2. Projectの構成
第2章ではアプリケーションの構成を解説しています。以下が今回実装するディレクトリ構成です。
.
├── __tests__
├── components
├── config
├── features
│ └── auth
│ ├── api
│ ├── components
│ └── types
├── layouts
├── lib
├── pages
├── providers
├── stores
├── testing
├── types
└── utils
それぞれ説明します。
- components
- アプリケーション全体で共有されるコンポーネントを定義
- ButtonやFormなど
- config
- アプリケーションで使用される設定ファイルを管理
- features
- featureごとにモジュールを管理
- layouts
- ページごとに異なるLayoutを定義
- lib
- ライブラリの設定を管理
- pages
- アプリケーションのPageを定義
- Next.jsのfilesystem-based routingが適用される
- providers
- アプリケーション全体でwrapされるProviderを定義
-
_app.tsx
にてwrapすることで使用
- stores
- Globalに管理するStateを定義
- testing
- テストに関するmockデータや便利関数などを定義
- types
- アプリケーション全体で使用されるTypeScriptの型定義を管理
- utils
- 便利関数を定義
featuresディレクトリについて
この中でも特にfeaturesディレクトリが特徴的です。feature-basedな分け方をすることによって、メンテナンス性を高め、よりスケーラブルなアプリケーションになります。さらにfeaturesの中を見ると、api
、components
、types
、index.ts
で構成されています。
features/auth
├── api
├── components
├── types
└── index.ts
- api
- APIリクエストを管理
- UIとの責務を分離する
- components
- featureに関心のあるcomponentを管理
- types
- 型定義
- index.ts
- エントリーポイント
- 外部に公開するもののみを選択してexportする
feature-basedな設計を採用する際のポイントとして以下の点が重要です。
外部からfeature内のコードを使用する際は、index.ts(エントリーポイント)からのみimportする
// good 🟢
import { JobsList } from "@/features/jobs"
// bad ❌
import { JobsList } from "@/features/jobs/components/jobs-list"
このルールを徹底することで、feature内のコードをリファクタリングしても外部で使用しているコードへの影響を最小限に抑えることができます。また、各feature間の関心を適切に分離することで、新規の機能や概念が追加された時にも柔軟に対応することができるようになります。
feature-basedな設計に関して以下の記事もとても参考になりますので参照ください。
🐹 3. 汎用Componentを実装する
第3章では汎用性の高いコンポーネントを作成します。この章で特に学びになったポイントの紹介です。UIコンポーネントライブラリにはChakraUIを採用しています。
スタイリングにvariantsオブジェクトを使用する
Buttonコンポーネントのように汎用性の高いコンポーネントは、使用する側から良しなにスタイルを当てたいケースがあります。その場合、直接propsでスタイルを指定することも当然可能です。しかし、デザインパターンに対応したスタイルのオブジェクトを作成し、そのKeyをpropsで受け取ることで一つ一つスタイルを指定する実装を回避することができます。
const variants = {
solid: {
variant: 'solid',
bg: 'primary',
color: 'primaryAccent',
_hover: {
opacity: '0.9',
},
},
outline: {
variant: 'outline',
bg: 'white',
color: 'primary',
},
};
export const Button = ({
variant = 'solid',
type = 'button',
children,
icon,
...props
}) => {
return (
<ChakraButton
{...props}
{...variants[variant]}
type={type}
leftIcon={icon}
>
{children}
</ChakraButton>
);
};
Storybookでカタログ化する
Storybookとは、UIカタログを作成するツールであり汎用性の高いコンポーネントを開発、検証する際にとても便利なツールです。
Storybookを使用しない場合、作成したコンポーネントをページでレンダリングして確認する必要があります。しかし、すべてのコンポーネントをそのように確認するのは大変であるため、カタログ化し検証することにします。
Storybookでは1つのコンポーネントでもいくつかのパターンに分けて検証することが可能です。これによって、開発者同士でのドキュメントの役割はもちろん、ビジネスサイドとのコミュニケーションにも役立ちます。
⛑️ 4. Page、Routingを実装する
この章ではPageとRoutingの実装を行います。
本書ではNext.js 12を使用しています。
そのため、Next.js 13におけるapp dirなどの新しい概念とは異なることを注意してください。
File-System basedなルーティング
Next.js はページという概念に基づいて、ファイルシステムに沿ったルーターを持っています。pages
ディレクトリにファイルが追加されたとき、ルートとして自動で使用可能になります。
pages
├── 404.tsx
├── _app.tsx
├── auth
│ └── login.tsx
├── dashboard
│ └── jobs
│ ├── [jobId].tsx
│ ├── create.tsx
│ └── index.tsx
├── index.tsx
└── organizations
└── [organizationId]
├── index.tsx
└── jobs
└── [jobId].tsx
今回は上記のようなルーティングを実装しました。
ブラケット記法([]
)を使用している箇所では動的なルーティングが行われます。
-
pages/organizations/[organizationId]/index.tsx
→/organizations/:organizationId
(/organizations/1
)
ページごとに最適化したレンダリング戦略
この章の中でも最も参考になったのは、ページごとに分けられたレンダリング戦略です。改めて以下のようにレンダリングの戦略を分けています。
- 誰もが閲覧可能なページ
- Server-side rendering(SSR)
- 管理者用のページ
- Client-side rendering(CSR)
誰もが閲覧可能なページ
このページは誰でも閲覧することが可能なため、SEOを意識してサーバでのレンダリング(SSR)を行います。SSRを実装するためには、getServerSidePropsを使用します。getServerSidePropsで実装された内容はサーバで実行され、ページのpropsへと渡されます。
export const getServerSideProps = async () => {
const jobs = await getJobs();
return {
props: {
jobs,
},
};
};
const PublicOrganizationPage = ({
jobs,
}: PublicOrganizationPageProps) => {
return (
{/* 省略 */}
);
};
管理者用のページ
管理者用のページではSEOを意識する必要はないのでクライアント側でレンダリングする方式を採用します。ここで一つ疑問なのが、「なぜすべてSSRを採用しないのか?」ということです。SSRには以下のデメリットが存在します。
- サーバ側の負荷が増える
- getServerSidePropsの実行中はアプリケーション全体をブロックしてしまう
そのため、SSRは確かな理由がある時に使用するべきであり今回の管理者ページのような頻繁に更新されうる要件の場合はCSRを使用することが望ましいと考えられます。
🧶 5. APIをモックする
第5章ではAPIのモックを実装します。4章まででページのUI実装が完了しました。しかし、まだ表示しているデータはテストデータでありAPIのリクエストは行なっていません。そこでAPIのモックを実装してバックエンドの実装を待たずにAPIリクエストの検証を行いましょう。
使用するライブラリはMSWです。
なぜモックは便利なのか?
APIをモックするメリットは以下の4つにあります。
- 外部のサービスから独立して開発することができる
- Backendの実装を待たずして開発を行える
- プロトタイプの実装に適している
- MVP(Minimum Viable Product)を最速で作成できる
- オフラインでの実装が行える
- テストに使用できる
MSWの仕組み
MSWはAPIをモックするためのライブラリです。Service Workerを利用して、アプリケーションとAPI間の通信をリアルタイムでインターセプトし、定義されたモックレスポンスを返すことができます。これにより、開発やテスト時にbackendサーバーに依存せず、コントロールされた環境でAPIレスポンスをシミュレートできます。
MSWの特徴として、Networkレベルで通信をインターセプトするため、ブラウザ開発環境のNetworkタブでリクエストの詳細を確認できることも大きなメリットです。
💍 6. APIと繋ぎこみする
第6章では前章で用意したAPIとの繋ぎこみを行います。使用するライブラリは以下の通りです。また、React Queryを使用するのはCSRのレンダリング戦略を採用しているコンポーネントに限定されます。
- HTTPクライアント
- Axios
- Server State管理
- React Query
なぜReact Queryを使うのか?
React Queryは非同期の状態を管理するのにとても有効な選択肢です。errorやloadingの状態も私たちが内部の詳細な実装を気にすることなく扱うことができます。また、キャッシュ機構も非常に強力です。useQueryのquery keyに一意な値を渡すことで、React Queryがデータをキャッシュしてくれます。
React Queryの設計に関しては、別の記事にて解説をさせていただいているのでぜひご覧ください!
🦊 7. 通知機能を実装する
アプリケーション内で何かしらのアクションが成功した場合、失敗した場合にページを跨いでユーザに知らせたいケースがあります。
そのような場合、通知に関する状態を管理する必要がありますが、これらの状態はGlobalに保持しておきたいです。
今回は、Globalな状態管理にZustandというライブラリを使用します。
Zustandは非常に軽量でFluxの概念を用いた状態管理ライブラリです。
そして以下のように実装します。
import { create } from 'zustand'
const useNotificationStore = create((set, get) => ({
notifications: [],
showNotification: (notification) => {
const id = uid();
set((state) => ({
notifications: [
...state.notifications,
{ id, ...notification },
],
}));
// 指定の時間で削除する
if (notification.duration) {
setTimeout(() => {
get().dismissNotification(id);
}, notification.duration);
}
},
dismissNotification: (id) => {
set((state) => ({
notifications: state.notifications.filter(
(notification) => notification.id !== id
),
}));
},
}));
// 使用する側
const { showNotification } = useNotifications();
const onSuccess = () => {
showNotification({
type: 'success',
title: 'Success',
duration: 5000,
message: 'Job Created!',
});
};
🐼 8. テスト
第8章ではテストを実装します。今回のアプリケーションでは単体テスト、結合テスト、E2Eテストについて扱っておりそれぞれ使用するライブラリは以下の通りです。
- 単体テスト
- Jest
- 結合テスト
- Jest、React Testing Library
- E2Eテスト
- Cypress
単体テスト
単体テストは他のモジュールに依存することなく独立した単位でメソッドのテストをすることを言います。対象となるのは、通知機能に当たるメソッドです。src/stores/notifications/__tests__/notifications.test.ts
というファイルを作成しテストを実装していきます。
import {
notificationsStore,
Notification,
} from '../notifications';
const notification = {
id: '123',
title: 'Hello World',
type: 'info',
message: 'This is a notification',
} as Notification;
describe('notifications store', () => {
it('should show and dismiss notifications', () => {
expect(
notificationsStore.getState().notifications.length
).toBe(0);
notificationsStore
.getState()
.showNotification(notification);
expect(
notificationsStore.getState().notifications
).toContainEqual(notification);
notificationsStore
.getState()
.dismissNotification(notification.id);
expect(
notificationsStore.getState().notifications
).not.toContainEqual(notification);
});
});
テストの概要としては以下の振る舞いをテストしています。
- 初期値としてnotificationsは空配列である
- showNotificationアクションを呼ぶと、notificationsの配列に要素が追加される
- dismissNotificationアクションを呼ぶと、notificationsの配列から要素が取り除かれる
このように単体テストでは、独立した個々のモジュールごとにテストを書いていき、期待する挙動を保証することができます。
結合テスト
結合テストは複数モジュールを組み合わせて実装されるコンポーネントをテストする手法です。複数のモジュールがそれぞれどのように通信しているかをテストできるため、単体テストに比べてもより有用でアプリケーションの品質を保証するものになります。
結合テストのutilsを定義する
この中でも特に参考になったのは、結合テストのために用意したutils関数です。
一つ目は、アプリケーションで使用されるProviderをラップしたappRender
関数です。
// renders the app within the app provider
export const appRender = (ui: ReactElement) => {
return render(ui, {
wrapper: AppProvider,
});
};
この関数ではアプリケーションで使用されているProviderをあらかじめラップしています。これにより、テストコードでそれぞれ必要なProviderをラップする必要がなくなるので、よりテストの実装に意識を向けることができます。
このProviderをラップする実装は公式でも推奨されています。
また、ローディングが完了したことをテストするwaitForLoadingToFinish
関数も実装しています。
// waits for all loading spinners to disappear
export const waitForLoadingToFinish = () => {
return waitFor(
() => {
const loaders = [
...screen.queryAllByTestId(/loading/i),
...screen.queryAllByText(/loading/i),
];
loaders.forEach((loader) =>
expect(loader).not.toBeInTheDocument()
);
},
{
timeout: 4000,
}
);
};
以上二つの便利関数を紹介しましたが、実際テストコードにおける過度な共通化はテストの読者にとって可読性を下げてしまうことがあるため注意も必要です。
E2Eテスト
E2Eテストはアプリケーション全体をテストする手法です。E2Eでは、正常系のケースをバックエンドとの通信も含めてテストします。今回は、Cypressを使用して実装をします。
describe('dashboard', () => {
it('should authenticate into the dashboard', () => {
cy.clearCookies();
cy.clearLocalStorage();
cy.visit('http://localhost:3000/dashboard/jobs');
cy.wait(500);
cy.url().should(
'equal',
'http://localhost:3000/auth/login?redirect=/dashboard/jobs'
);
cy.findByRole('textbox', {
name: /email/i,
}).type(user.email);
cy.findByLabelText(/password/i).type(
user.password.toLowerCase()
);
cy.findByRole('button', {
name: /log in/i,
}).click();
cy.findByRole('heading', {
name: /jobs/i,
}).should('exist');
});
});
基本的にはシナリオに沿って、画面の遷移やユーザのアクションを定義して期待する挙動になるかをテストしていきます。
テストコードが書けたら、上のように視覚的にテストの実行結果を確認できる点も非常に体験がいいです。
以上、単体、結合、E2Eテストを紹介しましたが、フロントエンドにおいてもテストの重要性は高いです。適切なテスト運用をすることでサービスの品質を向上させ、より一層機能開発に集中することができるでしょう。
以下の記事は私がテスト実装において重要な点をまとめたものになります。
👜 9. CI/CD
最終章ではCI/CDにおける実装を紹介しています。また、プロジェクトはGitHubで管理されていることから、GitHub Actionsを使用します。
CI/CDとは?
それぞれ、Continuous Integration/Continuous Deployment(Delivery)の略称であり、継続的インティグレーション/継続的デプロイ(デリバリー)を意味します。
- 継続的インティグレーション
- コードをビルドし、テストが実行され、問題がないことを確認した上でマージする一連の流れを自動化すること
- 継続的デリバリー
- 変更をレポジトリに反映させること
- 継続的デプロイ
- レポジトリの変更を本番環境に反映させること
コードのチェックと単体・結合テストを実行する
まず、pushされたコードがESLint、Prettierのフォーマットに沿っているか、そして単体・結合テストに成功しているかを確かめるCIを実装します。
rootディレクトリに.github/workflows/main.yml
を配置し、CI/CDのコードを書いていきます。
name: CI/CD
on:
- push
jobs:
code-checks:
name: Code Checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
- run: mv .env.example .env
- run: npm install
- run: npm run test
- run: npm run lint
- run: npm run format:check
- run: npm run types:check
ここでは、pushのイベントが実行された時に毎回スクリプトが走るように定義しています。また、jobsには複数のjobを定義することができ、今回はコードが適切かをチェックするcode-checks
というjobを作成しました。
流れは以下の通りです。
- まず、アプリケーションに必要な環境変数をコピーする
- 必要な依存関係をinstallする
- その後、ESLintチェック、Prettierチェック、型チェック、テスト実行を行う。
actions/checkout@v3
はrepositoryのアクセス権限を与えるためのアクションです。
E2Eテストを実行する
E2Eテストの実装は第8章で行いました。そのため、CIで実行することで毎回のpush時にE2Eテストが通っていることを保証できます。
e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: mv .env.example .env
- uses: cypress-io/github-action@v4
with:
build: npm run build
start: npm run start
流れは以下の通りです。
- 必要な環境変数をコピーする
- 依存関係をinstallする
- アプリケーションをbuildしてテストを実行する
cypressのE2Eテストを実行するために、cypress-io/github-action@v4
というアクションが用意されています。
Vercelにデプロイする
これまでで、コードのチェックと全てのテストに成功されていることが保証されました。では、本番環境に継続的デプロイする仕組みを実装します。
deploy:
name: Deploy To Vercel
runs-on: ubuntu-latest
needs: [code-checks, e2e]
if: github.repository_owner == 'alan2207'
permissions:
contents: read
deployments: write
steps:
- name: start deployment
uses: bobheadxi/deployments@v1
id: deployment
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: ${{ fromJSON('["Production", "Preview"]')[github.ref != 'refs/heads/master'] }}
- uses: actions/checkout@v3
- run: mv .env.example .env
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-args: ${{ fromJSON('["--prod", ""]')[github.ref != 'refs/heads/master'] }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID}}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID}}
scope: ${{ secrets.VERCEL_ORG_ID}}
working-directory: ./
- name: update deployment status
uses: bobheadxi/deployments@v1
if: always()
with:
step: finish
token: ${{ secrets.GITHUB_TOKEN }}
status: ${{ job.status }}
env: ${{ steps.deployment.outputs.env }}
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
流れは以下の通りです。
- forkされたレポジトリのpushを反映させたくないため、レポジトリのオーナーをチェックする
- ここで一致しない場合は、デプロイをキャンセルする
- デプロイのステータスをstartにする
- Vercelにデプロイする
- 完了したらステータスをfinishにする
needs: [code-checks, e2e]
によって、デプロイするためには上記二つのCIの成功が条件になることを設定できます。仮に、テストで失敗した場合デプロイ処理は実行されません。
また、Vercelは初期状態でCDを搭載しており特に設定をすることなくpushをトリガーにしてデプロイ作業を行なってくれます。しかし、今回はデプロイ以前にコードチェックとテスト実行を行いたいため、一度Vercelのデプロイ設定を解除して明示的にデプロイ実行を行なっています。
まとめ
いかがでしたでしょうか。今回は、「React Application Architecture for Production」という本を紹介するとともにReactアプリケーションの実装からデプロイまでの一連の流れを解説しました。私自身、「Reactのメリットは柔軟性であり、その柔軟性はデメリットにもなりうる」という言葉が非常にReactの特徴を表していると思っています。そのため、このプロジェクトもあくまで一つの例であり、サービスの要件によって最適なアプリケーション設計を考える必要性を改めて感じさせられる一冊でした。