1年越しの反省を活かしたフロントエンドの技術選定と設計
こんにちは。株式会社AI Shiftの安井です。今回は約1年前に行った技術選定の振り返りと1年間運用したことで得られた知見をもとに新しく立ち上がったプロジェクトではどのような技術選定と設計をしたのかまとめたいと思います。
前提
私たちはLLMを活用した業務効率化プラットフォームをtoB向けに開発しています。現在はブラウザで利用されることが前提にありますが、立ち上げ当初はMicrosoft Teamsアプリ上で利用されるケースも想定していました。
所属する開発チームはBackend(2人)、Frontend(私含め2人)、Designer(1人)の構成です。このメンバーで現在2つのサービスを運用しています。そして今回立ち上がったプロジェクトを含めると合計3つになります。
またtoBのプロダクトのためLighthouseなどパフォーマンスの指標を追求する必要はありませんが、ユーザ(利用する従業員の方)がダイレクトに使用されるため高いUXを提供する必要があります。
䷿ 1年前の技術選定と設計
1年前の技術選定時点では立ち上げの1ヶ月ほど後にジョインしたため、全て私が技術選定を行ったわけではありません。しかし、立ち上げ期に入れたことからいくつか技術選定に貢献することができました。
採用した技術
本プロジェクトはTypeScriptとReactを基本にしています。Next.jsやRemixのようなフレームワークは採用していません。
- Vite(ビルドツール)
- TanStack Router(ルーティング)
- TanStack Query(非同期状態管理)
- Orval(コード自動生成)
- Jotai(グローバル状態管理)
- React Hook Form(Form管理)
- Zod(バリデーション)
- Fluent UI(UIライブラリ)
- Griffel(CSSライブラリ)
- Vitest(テスト)
- Biome(Linter/Formatter)
ディレクトリ設計
ディレクトリ設計はTanStack RouterのFile Basedルーティングを基本に対象のルートで必要なコンポーネントはそのルートの中に配置するようにしました。
これはTanStack RouterのいくつかのAPIが対象のルートの中でのみ使用可能であることから、そのコンポーネントがどこで使用されるかを明確にする必要があると考えたためです。
// このAPIを/posts以外のルートから呼び出すとエラーになる
const routeApi = getRouteApi('/posts')
またLayer Basedなディレクトリ設計と比較して、各ルートの中にfunctionsやstates、typesを配置することにより関心が集約されると考えました。
.
└── src/
├── routes/
│ └── home/
│ ├── route.tsx
│ ├── route.lazy.tsx
│ ├── -components/
│ │ └── Container.tsx
│ ├── -functions/
│ │ ├── sort.ts
│ │ └── sort.test.ts
│ ├── -states
│ └── -types
├── ui/
│ └── Button.tsx
└── utils/
└── date.ts
よかった点
積極的ではあったがFile Basedルーティングを採用した点
TanStack Routerは2023年12月にv1になったライブラリで、約1年前からすると比較的新しいルーティングライブラリでした。その上でAI Shiftでは初期からexperimentalだったFile Basedルーティングを採用しています。
今となっては当たり前のように利用できるFile Basedルーティングですが、2024年1月時点ではフレームワークを使用せずに実現する選択肢は少なかったように思います。
実際File Basedルーティングの機能がTanStack RouterでStableになるまでは何度も破壊的な変更がされるため、毎週のようにバージョンを上げては設計を見直すことがありました。
しかし、結果的にはCode Basedルーティングに比べてルーティングへの関心が減り、よりコンポーネントの実装に集中ができている実感があります。
改善したい点
一方で初期の設計には改善したいポイントが数多くあります。
ルーティングが深くなった場合の見通しが悪くなる
先ほど紹介した「対象のルートで必要なコンポーネントはそのルートの中に配置する」設計ではルーティングが深くなった場合に該当のコンポーネントがどこに配置されているのかが分かりづらくなります。
.
└── src/
└── routes/
├── project
└── route.tsx/
├── $projectId
└── route.tsx/
├── reports
└── route.tsx/
├── $reportId
└── route.tsx/
├── detail
└── route.tsx
当初はこのようにURLが深くなるアプリケーションを想定していなかったため問題は起きませんでしたが、運用をしていく過程でルーティングが深くなった際に見通しが悪くなってしまいました。
コンポーネントのテストが実装しづらい
純粋関数単位でのUnitテストはfunctionsの中でそれぞれ実装をするようにしていましたが、Testing Trophyの観点で最も実装に対するリターンが大きいとされるIntegrationテストの実装が考慮されていませんでした。
なぜこのディレクトリ設計でIntegrationテストが実装しづらいかという点を言語化すると2つの観点があります。
1つ目はどの単位でIntegrationテストを実装するかが明確ではないことです。ルートに必要なコンポーネントは全てルートに紐づく-components
に配置するというルールのみ決まっていますが、どの単位でコンポーネントを分割するかは明示的ではありませんでした。
2つ目はコンポーネントに直接ロジックを書いてしまっているためモックがしづらいことです。例えばContainer/PresentationalパターンのようにUIとロジックが分離されていればモックのデータをprops越しに注入するだけで純粋なUIをテストすることが容易になります。
ディレクトリ設計:再掲
.
└── src/
├── routes/
│ └── home/
│ ├── route.tsx
│ ├── route.lazy.tsx
│ ├── -components/
│ │ └── Container.tsx
│ ├── -functions/
│ │ ├── sort.ts
│ │ └── sort.test.ts
│ ├── -states
│ └── -types
├── ui/
│ └── Button.tsx
└── utils/
└── date.ts
🆕 1年後の技術選定と設計
これまで約1年前に行った技術選定と設計の振り返りをしました。次に新しく立ち上がったプロジェクトで過去の反省も活かしどのような改善をしたかを整理します。
採用した技術
今回もTypeScriptとReactを基本にフレームワークを採用せずに実装しています。また、採用するライブラリ自体は大きく変更していませんが、違いとしてUIライブラリにshadcn/uiとTailwind CSSを採用した点があります。
- Vite(ビルドツール)
- TanStack Router(ルーティング)
- TanStack Query(非同期状態管理)
- Orval(コード自動生成)
- Jotai(グローバル状態管理)
- React Hook Form(Form管理)
- Zod(バリデーション)
- Fluent UI → shadcn/ui(UIライブラリ)
- Griffel → Tailwind CSS(CSSライブラリ)
- Vitest(テスト)
- Biome(Linter/Formatter)
フレームワークを選ぶ選択肢も十分にあった
2024年初めの頃に比べNext.jsのApp Routerも安定し積極的なキャッシュに見直しが入るなどの改善からプロダクトで採用することも検討されました。
また最近ではReact Router v7がリリースされRemixと統合しフレームワークとして有力な候補になりました。しかし、そんな中で今回もフレームワークを選択せずにReact + Viteの構成を選んだ理由は以下になります。
1つ目は「人数に対してプロダクトが多いため、プロダクト間の知識を揃えたかった」点です。冒頭でも述べたように私たちのチームは2人のFrontendメンバーに対して3つのプロダクトを抱えています。そのため、なるべくプロダクトを跨いだ際のコンテキストスイッチを低く抑える必要がありました。
React Routerをフレームワークとして採用しなかった理由もこの点が大きく影響しています。フレームワークを採用するということはその思想や設計に乗せることが重要だと思っています。その点、React Router(Remix)のデータフローは以下のようになっており、これはTanStack Queryを使用していたデータフローとは意識を変える必要がありました。
2つ目は「サーバの関心を持つコストに対してメリットを教授できなかった」点です。私たちのプロダクトはtoB向けでありUXは重要ですがパフォーマンスの指標が直接ビジネスに影響することはありません。
もちろんRSCの設計思想は非常に魅力的であり、長期的に早くから採用することのメリットは十分にあると考えます。しかしその場合、静的にビルド結果を配信するだけでは不十分で、今まで意識をしていなかったサーバの費用的なコストと監視をするコストが発生します。
これらの要素を鑑みると私たちのチームにおいて、このタイミングで採用することはややtoo muchだと判断しました。
ディレクトリ設計
今回のディレクトリ設計は前回の反省を活かしてfeature basedな設計に変更しました。これによってroutes配下にはルーティングのコンポーネントのみが配置され、URLの階層が深くなったとしても見通しが悪くなることはありません。
.
└── src
├── routes
│ └── home
│ └── route.tsx
├── features
│ └── report
│ ├── components
│ │ └── ReportBoard
│ │ ├── ReportBoard.tsx
│ │ ├── ReportBoard.fallback.tsx
│ │ ├── ReportBoard.error.tsx
│ │ ├── ReportBoard.hook.ts
│ │ └── ReportBoard.test.tsx
│ ├── states
│ ├── types
│ └── functions
├── components
│ └── ui
└── utils
設計の解説
featuresをルーティングの関心から分離する
前回の設計でroutes
の中に必要なコンポーネントを配置した背景として、TanStack RouterのいくつかのAPIが対象のルートの中でのみ使用可能であるという条件に対応する目的がありました。
ですが今回はTanStack RouterのAPIを直接使用できるのはroutes内のみとすることで、featuresでルートの情報が必要な場合はpropsとして注入する形にしています。これによってfeaturesの中にはルーティングの知識が入ることがなく、仮にルーティングのライブラリが変更されてもfeaturesが受ける影響を最小限に抑えられます。
Suspenseを活かしたコンポーネント設計
features内の各コンポーネントには以下5つのファイルを配置しています。
- 純粋なUIを実装したコンポーネント(ReportBoard.tsx)
- Suspenseのfallback時に表示されるコンポーネント(ReportBoard.fallback.tsx)
- ErrorBoundaryがcatchした際にfallbackされるコンポーネント(ReportBoard.error.tsx)
- コンポーネントのロジックをまとめたカスタムHook(ReportBoard.hook.ts)
- コンポーネントのテスト(ReportBoard.test.tsx)
// 省略
├── features
│ └── report
│ ├── components
│ │ └── ReportBoard
│ │ ├── ReportBoard.tsx
│ │ ├── ReportBoard.fallback.tsx
│ │ ├── ReportBoard.error.tsx
│ │ ├── ReportBoard.hook.ts
│ │ └── ReportBoard.test.tsx
この中でも意識しているのが「Compound Pattern」を採用している点です。
これはSuspenseとErrorbondaryで表示されるFallbackのUIはコンポーネント側で定義するものであり、コンポーネントを利用する側が関心を持つものではないという前提に立っています。
export const ReportBoard: React.FC<Props> & {
Error: typeof ReportBoardError
Loading: typeof ReportBoardFallback
} = () => {
// hooks内ではSuspenseQueryを使用している
const { data } = useReportBoard()
// 省略
return ...
}
ReportBoard.Error = ReportBoardError
ReportBoard.Loading = ReportBoardFallback
コンポーネント内でのデータ取得にはSuspenseQueryを使用しており、データ取得中にはコンポーネントがサスペンドされ、取得に失敗した際にはErrorがThrowされます。その際に、それぞれのFallback用のコンポーネントを用意する必要がありますが、この実装を使用側で定義するのではなく、コンポーネント側に寄せることで関心を集約しています。
<ErrorBoundary FallbackComponent={ReportBoard.Error}>
<Suspense fallback={<ReportBoard.Loading />}>
<ReportBoard />
</Suspense>
</ErrorBoundary>
実際に利用する際にはReportBoard
コンポーネントに紐づく形でErrorとLoadingのFallbackコンポーネントを使用することができます。
よかった点
Integrationテストの実装が書きやすくなった
前回の設計に比べてfeature basedなディレクトリ配置をすることでIntegrationテストの実装がしやすくなりました。
まず何よりコンポーネントからロジックをカスタムHookに切り出すことでモックが容易になっています。そして、テストが可能な単位でコンポーネントを分割する意識が生まれたことも大きなメリットだと思いました。
そしてIntegrationテストのツールにはなるべくブラウザに近い実行環境でテストしたいという観点から、ExperimentalではあるもののVitest Browser Modeを採用しました。
まだ新規プロジェクトのため仕様の変更も多くテストを充実させるフェーズではありませんが、今後どのようにCIに組み込んで運用していくかなどの検討が必要になります。
Griffel(CSS in JS)からTailwindCSSへ
今回はGriffel(CSS in JS)からTailwindCSSへ変更しました。もともとGriffelを採用していた背景としてMicrosoft Teamsアプリ上での利用が想定されていたため、Teamsアプリに近いUIを実装するためにFluent UIというUIライブラリを使用する必要があった背景があります。
CSS in JSとユーティリティクラスの比較は実装者の好みがあると考えるので深くは言及しませんが、私のチームにおいてはAIとの親和性の観点でTailwindCSSが相性がいいと感じました。
AI Shiftの開発チームではCursorのProプランを契約しています。
その際にTailwindCSSの場合はCSSの記述がHTMLの構造と紐づいているのに対して、CSS in JSではHTMLの構造外でスタイルを定義する必要があります。これはAIの推論の精度やスピードにも影響してくると感じました。
まとめ
今回は1年前に行った技術選定の反省をもとに新規のプロジェクトで何を意識したかを振り返りしました。特にディレクトリ構成をRouting BasedからFeature Basedへ移行したことによるテスタビリティの向上やSuspenseを活かした関心の分離などは改善されたポイントだと思います。
当然今回の技術選定と設計に関しても1年後には反省点が多く見つかると思いますが、常に理由を持った意思決定と細かい振り返り・改善を繰り返すことで変化に強いプロダクトを目指していきたいです。
最後に
AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
Discussion