React実務未経験者だけで進めた、新規プロダクトのフロントエンド開発 振り返り(採用ライブラリ / コンポーネント設計)
こんにちは!
RecustomerのTatsukiです!
この記事では、弊社のフロントエンドについて、採用ライブラリやコンポーネント設計について紹介します。
タイトルの通り、React実務未経験者だけで開発を進めましたので、
- これからReactやNext.jsのプロジェクトを導入予定
- ジュニアメンバーが多いチームでのコンポーネント設計や採用ライブラリを検討している
という方には、お役に立てれば嬉しいです!
背景 - VueからReactへフロントエンドフレームワークの変更
私がジョインする前、フロントエンドはVue.jsを使用していました。しかし、フロントエンドの開発はアウトソーシングしていたため、下記問題が発生していました。
- コンポーネントが一定のルールで分割されていないため、可読性 / 保守性 / 再利用性が低い
- ロジックがかなり複雑でありながらテストコードが存在しないため、修正した後の多機能へのデグレードがないことを保証することが難しい
- グローバルState(Vuex)と各コンポーネントで保持するState(v-model)の使用がルール化されておらず、APIで取得したデータがどこに保持されているか、Inputされたデータをどこに保持しているか読み解くのに時間が必要
上記のような問題については、フロントエンドだけではなく、バックエンドでも起きていました。そこで、プロダクトとしての成長 / 拡張性を向上させるためにもプロダクトのメジャーアップデートプロジェクトがスタートしました。
その際、フロントエンドのフレームワークをVue.jsからReactに変更することを決定いたしました。決定した背景は下記観点によるものです。
- Vueのバージョンアップデートを対応することが難しい
- 採用観点
Vueのバージョンアップデートを対応することが難しい
ご存知の方も多いと思いますが、Vue2 -> Vue3からメジャーアップデートがありました。そのメジャーアップデートについては破壊的変更が多く、仮にVue3 -> Vue4でも同様なアップデートがある場合、期間が3ヶ月 ~ 6ヶ月程度の一大プロジェクトとなる可能性が高いと仮説を立てました。Recustomerはスタートアップ企業であり、より早く価値を提供する必要があるフェーズであります。新規の価値を提供するのではなく、長期で使えるようにメジャーアップデートすることに多くの時間が取られてしまうことに危機感を感じました。
採用観点
元々アウトソーシングしていたため、フロントエンドエンジニアが不足しています。そこでフロントエンドの開発経験があるエンジニアの方に来ていただけるような環境を準備できればと考えていました。感覚的なものではありますが、日本ではフロントエンドの技術スタックとして、Reactを採用した方が魅力的なのでは?と仮説を立てました。
実務経験者いないけど、どうする?
上記のようにReactを採用することを決めましたが、実際にReactを実務経験したメンバーはいませんでした。その中で私と +2人の計3人でフロントエンドのライブラリ選定、コンポーネント設計を行う必要がありました。
何もかも手探りの状態ですが、「まずやってみよう」の精神で開発をスタートし、開発にストレスがあることや技術的な問題が起きたら、その都度ディスカッションをするように開発を進めました。
特に意識したのは、「参考のベースにするリポジトリを1つにして、それをベースに自分たちのアレンジをする」ことです。
コンポーネント設計やプロジェクトの考え方には正解がなく、さまざまな情報をそのまま導入してしまうと思想がまとまっていないプロジェクトとなり、カオスになることを懸念していました。そのため、参考リポジトリを1つに絞り骨組みを作成し、そこに様々な情報を肉付けしていくように選定を行いました。
今回参考にしたリポジトリは下記リポジトリです。
ページ下部には試行錯誤の際に参考になったサイトや書籍も紹介していますので、ぜひご覧ください。
現状のフロントエンドアーキテクチャ
フレームワーク / ライブラリ
フレームワークについては下記を採用しています。
{
"dependencies": {
"react": "^18.2.0",
"next": "^13.0.0",
"axios": "^1.4.0",
"@tanstack/react-query": "^4.29.12",
"sass": "^1.62.1",
"zustand": "^4.3.8",
"@sentry/nextjs": "^7.93.0",
},
"devDependencies": {
"eslint": "^8.26.0",
"prettier": "^2.7.1",
"storybook": "^7.0.10",
"jest": "^29.2.2",
"jest-environment-jsdom": "^29.2.2",
"chromatic": "^6.17.4",
}
}
Webアプリケーションフレームワーク
前述した通り、React(Next.js)を採用したしました。RoutingにはPage Routerを採用いたしました。App Routerを採用しなかった理由として、
- まだリリースされてすぐだったため、リファレンスが少なく、問題にぶつかった際に解決することが難しい
- 立ち上げ当時時点ではベータリリースだった
2024年3月時点ではApp Routerは正式リリースとなり、公式・Zennをはじめ様々なリファレンスが整ってきているため、リプレースを検討していければと考えております。
API通信
API通信にはaxiosを採用しました。また、プロダクトの使用上、API通信の回数が多いため、fetchのキャッシュを管理するためにtanstackを採用しました。
後述しますが、libディレクトリにaxiosをwrapしたような共通関数を作成し、APIを使用する場合には、必ずここ経由でデータ取得するように変更しました。
import axios, { AxiosInstance, AxiosResponse } from 'axios'
import { snakeToCamel } from 'utils/snakeToCamel'
import { API_URL } from 'config/constants'
const applyResponseInterceptor = (client: AxiosInstance): void => {
client.interceptors.response.use(
(response: AxiosResponse): AxiosResponse => {
response.data = snakeToCamel(response.data)
return response.data
},
(error: any): Promise<any> => {
return Promise.reject(error)
},
)
}
const createAxiosInstance = (baseURL: string | undefined, contentType: string): AxiosInstance => {
const client: AxiosInstance = axios.create({
baseURL: baseURL,
withCredentials: true,
headers: {
'Content-Type': contentType,
},
paramsSerializer: { indexes: null },
})
applyResponseInterceptor(client)
return client
}
export const apiClient: AxiosInstance = createAxiosInstance(API_URL, 'application/json')
snakeToCamel関数は、apiで取得したデータのkeyがスネークケースであるため、それをローワーキャメルに変換するutil関数です。これを共通部分で定義することで、各コンポーネントでデータfetchした際に、keyのパースについて考慮する必要がないようにしています。
type AnyObject = { [key: string]: any }
const toCamelCase = (s: string): string => {
return s.replace(/([-_][a-z])/gi, ($1) => {
return $1.toUpperCase().replace('-', '').replace('_', '')
})
}
export const snakeToCamel = <T extends AnyObject>(obj: AnyObject): T => {
const result: AnyObject = Array.isArray(obj) ? [] : {}
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
let value = obj[key]
if (typeof value === 'object' && value !== null) {
value = snakeToCamel(value)
}
result[toCamelCase(key)] = value
}
}
return result as T
}
また、意図的にcreateAxiosInstanceに関数を分割することで、別プロダクトや共通社内プラットフォームと通信したい際に、エンドポイントを変更した別インスタンスを定義するだけで、別サーバーとのAPI通信の下準備をすぐできるようにしています。
グローバルState
グローバルStateにはZustandを採用しました。採用理由としては軽量であり、使用方法がシンプルであることから、採用いたしました。
エラー通知 / 監視
エラー通知や監視にはSentryを採用しています。Next.jsへSentryを導入する際には「sentry/nextjs」が存在するため、これを使用することで、簡単に導入することが可能です。
エラーが発生した場合については、Slackに通知されるようにSentryを設定しています。
静的解析/フォーマッター
静的解析やLinterについてはEsLint、Prettierを採用しています。
EsLintの採用はメンバー間で話合い、開発を進めながら少しづつ調整や追加しました。
下記はEsLintの設定内容です。
{
"parser": "@typescript-eslint/parser",
(省略)
"extends": [
"next/core-web-vitals",
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"plugin:storybook/recommended"
],
"plugins": ["react", "complexity", "import", "unused-imports"],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"semi": "off",
"react/react-in-jsx-scope": "off",
"react/jsx-uses-react": "off",
"react/jsx-uses-vars": "off",
"prettier/prettier": [
"error",
{
"semi": false
}
],
"complexity": ["error", 10],
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/explicit-function-return-type": "error",
"no-console": "error",
"@next/next/no-img-element": "off",
"import/order": [
"error",
{
"groups": ["builtin", "external", "internal", ["parent", "sibling", "object"], "index"],
"alphabetize": { "order": "asc", "caseInsensitive": false },
"pathGroups": [
{ "pattern": "{react,react/**,react-dom/**,react-**}", "group": "builtin", "position": "before" },
{ "pattern": "{next,next/**,next-i18next,next-i18next/**}", "group": "builtin", "position": "before" },
{ "pattern": "@mui/**", "group": "external", "position": "before" },
{ "pattern": "lib/**", "group": "internal", "position": "before" },
{ "pattern": "stores/**", "group": "internal", "position": "before" },
{ "pattern": "utils/**", "group": "internal", "position": "before" },
{ "pattern": "types/**", "group": "internal", "position": "before" },
{ "pattern": "pages/**", "group": "internal", "position": "before" },
{ "pattern": "components/hooks/**", "group": "parent", "position": "before" },
{ "pattern": "components/layout,components/layout/**", "group": "parent", "position": "before" },
{ "pattern": "components/parts/**", "group": "sibling", "position": "before" },
{ "pattern": "components/wigets/**", "group": "sibling", "position": "before" },
{ "pattern": "components/templates/**", "group": "sibling", "position": "before" },
{ "pattern": "./**", "group": "object", "position": "before" },
{ "pattern": "../**", "group": "object", "position": "before" },
{ "pattern": "**.scss", "group": "index", "position": "before" }
],
"pathGroupsExcludedImportTypes": ["react"],
"newlines-between": "never"
}
],
"unused-imports/no-unused-imports": "error"
},
"globals": {
"React": "writable"
}
}
import/orderでimportの順番をかなり細かく指定し、Prettierで自動整形することでエンジニアが意識せずにimport部分をソートできるようにする部分であったり、complexityを指定することで、複雑かつ膨大な関数を作成されないような仕組みを作成しました。
テスト
テストには
- ユニットテスト・結合テスト => jestとReact Testing Library
- UIリグレッションテスト => storybookとChromatic
を採用しています。
特にリグレッションテストについては、仮説/検証 <=> フィードバックを繰り返すことから、スタイルやロジックの修正が多々入ります。UI/UXを担保するために、jestで検知することが難しい意図しないデザインの変更を防ぐためにChromaticを採用しました。
また、Chromaticを採用する際、変更されたコンポーネントのみデプロイされるようにCIを下記のように設定しています。
name: StoryBook Publish in Portal
on:
pull_request:
branches:
- develop
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Clean cache
run: |
npm cache clean --force
- name: Install dependencies
run: |
npm install
- name: StoryBook Publish to Chromatic
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
onlyChanged: true // これを追加することで変更されたコンポーネントのみデプロイされる
この設定がない場合、都度全てのUIコンポーネントがデプロイされ、すぐ無料枠を使い切ってしまう問題がありました。上記設定を行うことで、無料枠に収めることができています。
コンポーネント設計
ディレクトリ構成
下記が実際に使用しているフロントエンドのディレクトリ構成です。
L src
L components
L hooks
L layout
L parts
L widgets
L templates
L lib (api通信やテストコードで共通使用する関数)
L pages (各ページのrouting)
L stores(グローバルのstate(Zustand))
L tests (jestで書いたテスト)
L types (コンポーネント間を跨ぐ)
L utils (他プロジェクトでも汎用的に使用できそうな関数(ビジネスロジックに依存しない))
この記事では、component配下の一部についてご紹介します。
hooks (コンポーネントを跨ぐロジック)
hooksには、ビジネスロジックのうちコンポーネント間を跨ぐようなロジックがある場合に追加します。例えばユーザー情報を取得して加工する処理を別画面でそれぞれ使用する場合には、このhooksに作成します。
parts (ボタン / テキストなどのビジネスロジックが絡まない最小単位の汎用UI)
partsにはボタンやテキスト、ラジオボタンなどAtomic DesignでのAtomsに該当します。このコンポーネントは必ずビジネスロジックを絡まないように作成します。イメージとしては、別プロジェクトを立ち上げた際に、コピーするだけでそのまま活用できるようにしています。
また、必ずstorybookファイルを作成することで、新規の画面や機能を作成する際にコンポーネントのデザインや挙動を確認できるようにしています。
L parts
L {コンポーネント名}
L index.tsx (コンポーネントのDOMなど)
L style.module.scss (コンポーネント独自のスタイル)
L index.stories.tsx (コンポーネントのStorybook)
widgets (Partsを組み合わせた汎用UI)
widgetsには、AtomsのコンポーネントまたはMUIなどのデザインコンポーネントを組み合わせたコンポーネントを定義しています。Atomic DesisnでのMoleculesに該当します。ただし、このコンポーネントもビジネスロジックが流入しないように作成しています。
Parts同様に、storybookファイルを作成するようにしています。
L widgets
L {コンポーネント名}
L index.tsx (コンポーネントのDOMなど)
L style.module.scss (コンポーネント独自のスタイル)
L index.stories.tsx (コンポーネントのStorybook)
templates (PartsとWidgetsを組み合わせたページ本体)
templatesには各コンポーネントを組み合わせて、ページ本体を定義します。OrganismsとTemplatesに該当するコンポーネントで、このコンポーネント内にビジネスロジックは記載します。
また、ページ本体にもStorybookを定義することで、PartsまたはWidgetsでスタイルの変更があった際のページ全体への影響をChromaticのリグレッションテストで確認できるようにしています。
L templates
L {コンポーネント名}
L index.tsx (コンポーネントのDOMなど)
L hooks (コンポーネントのビジネスロジック)
L _container (index.tsxで繰り返し使用する、または切り出したコンポーネントを格納)
L style.module.scss (コンポーネント独自のスタイル)
L index.stories.tsx (コンポーネントのStorybook)
他コーディングルール
初期データ取得についてはPagesのgetServerSidePropsに限定
データを取得し、そのデータを伝播させるフローを1直線に統一するために、初期データの取得についてはgetSeverSidePropsで取得するよう制限しています。このようにすることで、データの取得場所が明確になり、コードを追うのが容易になるのではと仮説を立てています。
そのため、Pages配下についてはルーティングと初期データ取得のみの役割とし、getServerSidePropsでデータを取得する処理と、templateにPropsで渡す処理のみを記載するようにし、pagesにロジックやStyleを記載しないようにしています。
CI/CD
CI / CDにはGithub Actionを採用し、下記内容のスクリプトを作成しています。
- 自動ラベル付与 : デプロイ時などにPull Requestに自動でラベリングする
- ビルドテスト : num run buildを実行することで正常にビルドすることが可能かチェック
- Chromaticへのデプロイとリグレッションテスト : Chromaticにデプロイし、デザインの変更があった場合にはPull Request内で通知される
- テストのカバレッジ : ユニットテストと結合テストのカバレッジを調査し、Pull Requestのコメントに表示
- テスト実行 : ユニットテストと結合テストを実行
- 静的解析 : Linterのチェック
成果
Chromaticのリグレッションテストについては一定の効果がある
Storybookをtemplateについても作成することで、Pull Request作成のタイミングでデザイナーのレビューを実施することができたため、開発サーバーにデプロイした後のデザインチェック -> 別PRで修正という余計なコストをカットすることができたと思います。
また、cssの修正をした際に、デザインの影響範囲も明確になるためデザイン崩れなどのチェック工数も削減できたため、導入してよかったと感じています。
Storybookファイルの作成に追加の工数が必要とはなりますが、まずはChatGPTなど生成AIに作成してもらったものでも良いので、カバレッジが低くても作成することをお勧めします。
反省点 / 改善点
propsのバケツリレー問題
開発しているプロジェクトではgetSeverSidePropsで複数のAPIを叩くことや、レスポンスのプロパティが多い、またはネストが深いケースが多々ありました。その際にPages -> templates -> ... -> partsのように大量のデータを複数ファイルに跨いで伝播させていくため、可読性が低いと感じています。
ただ、一方でデータの取得先がPages配下で限定することで、pagesから順を追ってコードを読んでいけば良いというメリットもあるので、今後の課題としてデータ取得のルールをどのようにするか検討していきたいです。
例えば、contextを使用することや、compoundパターンを採用するなど、改善の余地がありそうです。
参考サイト/参考書籍
全体のアーキテクチャやコードについて参考にさせていただいています。
テストの考え方や設計について、かなり参考にさせていただきました。考え方から現場で使えるエッセンスまで詳細に書いてあるので、とてもお勧めです、
tanstack queryについては下記がとてもお勧めです。使い方・考え方など、こちらもすぐに現場に応用できるヒントがたくさんありました。
まとめ
React未経験者だけでスタートした開発ですが、様々な検証を重ねて現状のようになりました。まだまだ改善点などもあるので、改善を重ねながら開発を進められればと思います!
今回ご紹介したのは気軽に導入しやすいものだと思いますので、何かの参考になれば幸いです!
Discussion