CTO 室の恩田(@takashi_onda)です。
一休レストランのフロントエンドアーキテクトを担当しています。
Intro
一休レストランでは、以前ご紹介したようにフロントエンドで React / Remix を利用しています。
一方、設計方針としては、React / Remix への依存が最小になるように心掛けています。
今日は、そんな一見矛盾するような設計方針について、ご紹介したいと思います。
この記事を読んでいただき Remix に興味をもたれたら、明後日 2024/8/7(水) 19:00〜 のオンラインイベント
にもご参加いただけると嬉しいです。 この記事でご紹介している疎結合なフロントエンドアーキテクチャを実現する Remix の魅力についてお話します。
なぜ依存を最小にするのか?
React / Remix を使っていて依存しないってどういうこと?と疑問を持たれる方も多いでしょう。 まずはその動機からご説明します。
フレームワークやライブラリより寿命が長いプロダクトは珍しくありません。 栄枯盛衰の激しいフロントエンドでは、そういったサービスの方がむしろ多いのではないか、とも感じます。
一休レストランのサービス開始は2006年です。 同じ2006年に jQuery 1.0 がリリースされました。 まだ、フロントエンドという言葉が生まれる前の時代です。
一休レストランは、そんな時代から二度のリニューアルを経ながら(今は二度目の真っ最中)継続しているサービスです。 これからも広く使っていただけるよう日々、開発を進めています。
したがって、将来、エコシステムが大きく変わったとしても、その変化に少ない労力で追随し続けられることが重要となります。
フロントエンド領域も成熟してきているので、今後は、エコシステムが激しく入れ替わるような状況はもう訪れないかもしれません。 それでも、フレームワークやライブラリレベルでみると、メジャーバージョンアップは数年単位で見れば避けられず1、その中には破壊的変更を伴うものもあるでしょう。
バックエンド設計に倣う
バックエンド設計では、フレームワークへの依存を最小化する、という考え方が古くからあります。
すなわち、バックエンドの世界では、指針となる原則や、依存を最小にして疎結合にする技術が確立しているのです。
偉大な先人達の肩の上に乗るため、その知見はどういったものなのかを確認したいと思います。
依存性逆転の原則 (Dependency Inversion Principle)
ボブおじさんこと Robert C. Martin が提唱した SOLID 原則をご存知の方は多いと思います。その SOLID 原則の D 、依存性逆転の原則がフレームワークへの依存を最小化するための根幹の考え方になります。
(少々長くなるので、ご存知の方は読み飛ばしてください)
簡潔に説明すると、
- 高レベルモジュールであるドメイン層が自身の必要とする抽象 (interface) を定義し
- 低レベルモジュールにあたるインフラ層がその抽象を実現する詳細 (class) を実装する
ように設計すべし、という原則です。
高レベルモジュールが低レベルモジュールに直接依存するのではなく、低レベルモジュールが、高レベルモジュールの要求しているインターフェースの実装を提供する。 言い換えると、低レベルモジュールが高レベルモジュールの抽象に依存することから、依存性逆転の原則と呼ばれます。
少し雑に言えば、高レベルモジュールにあたるのが我々の開発するプロダクトです。 低レベルモジュールがフレームワークやライブラリと、それらを使ったインフラ層に相当します。
よく用いられる Repository の例で説明します。
プロダクトが知っているのは自身で定義した Repository の interface のみです。 プロダクトが Repository の実装を構成するライブラリやフレームワークの API を直接呼ぶことはありません。 Repository の interface もフレームワークが要求する interface から独立しています。
フレームワークへの依存が疎結合になりました。
腐敗防止層 (Anti Corruption Layer)
他にもバックエンドで培われた依存を最小化するテクニックとして、腐敗防止層は欠かせません。
DIP に比べると、シンプルでわかりやすいと思います。
ライブラリや外部のサービスを利用するときに wrapper を挟み、変更の影響をその wrapper に閉じこめるという手法です。 インターフェースや実装が安定しなかったり、将来交換する可能性が考えられるような場合に、特に有効です。
フロントエンドにどう適用するか?
「余計なことをしない」素直なフレームワークが大前提
現代のフロントエンド開発では、(メタ)フレームワークを利用することが一般的です。
まず、フレームワークに求められる要件を考えたいと思います。
「余計なことをしない」とは?
逆を考えるとわかりやすいかもしれません。
全部面倒を見てくれる、手厚いけれど、複雑な規約があり、その裏側がどうなっているかわからないフレームワークを思い浮かべましょう。フロントエンド・バックエンドは問いません。過去を振り返れば、だいたいどの言語にも一つはありそうです。
このような全部入りフレームワークは、
- そのフレームワークが想定しているユースケースの範疇でコードを書けていて、
- 運用面でも安定しているのであれば、
とても優れた開発効率や開発体験が得られるでしょう。
ですが、フレームワークが用意してくれているレールから外れないと実現が難しい要件は往々にして存在します。 そんな要件に遭遇してしまうと、フレームワークそのものに手を入れる以外の回避策しか見つからないことが多く、詰みます。2
また、そんな要件に出会わなかったとしても、そのフレームワークに密結合状態で依存しているだけで弊害は存在します。 たとえばメジャーバージョンアップで破壊的な変更が加えられたとき、その変更に追随するための苦労は過去にそのようなフレームワークを使ったことがある方なら想像に難くないと思います。
Remix の採用
さて、逆の状況を踏まえた上で、あらためてフロントエンドの世界で「余計なことをしない」フレームワークの条件を考えてみましょう。
標準 API の尊重、ブラックボックスがない、なだらかなアップデートパス、実装がシンプル、といった条件が挙げられます。
現時点において React ベースでは Remix が条件を最も満たしていると判断し、以前ご紹介したように、一休レストランでは Remix に乗り換えることを決断しました。
その上でバックエンド設計の原則に従い Remix API への依存は最小限になるよう努めています。
疎結合なアーキテクチャを実現するためには、逆説的ではありますが、フレームワークがそれを可能とする作りになっていることが重要なのです。
Remix だと、なぜそれが可能になるのかは、最初にご紹介したオンラインイベントでもお話する予定です。
React 非依存の Vanilla JS だけで使えるライブラリを選ぶ
一休レストランでは以前ご紹介した XState に加えて Jotai と TanStack Query を利用しています。
いずれも React に依存していないという共通点を持ちます。
TanStack シリーズは Vanilla JS のコアと各フレームワークへのアダプタで構成されています。
Jotai は TanStack シリーズのようにフレームワーク独立を謳っているわけではなく、React で使うことを前提とした状態管理ライブラリですが、公式サイトにも
Now with a store interface that can be used outside of React.
とあるように v2 からは、Vanilla JS で利用できる状態管理ライブラリとしての側面も持つようになりました。
コアの Vanilla JS 部分と React アダプタが別れているため、他のフレームワークと組み合わせて使うことが可能です。 たとえば Vue や Svelte, SolidJS のアダプタを作っている方もいるようです。
React / Remix での DIP
具体例として、一休レストランの soft navigation 機能をご紹介します。
一休レストランでの soft navigation では Remix の useLocation
や useNavigate
を直接使っていません。
アプリ側で、
useLocation
に対応するuseNavigationContext
useNavigate
に対応するuseEventNavigate
というカスタムフックをそれぞれ定義しています。
DIP の観点から見ると、useNavigationContext
, useEventNavigate
の signature が、高レベルモジュールの定義する抽象に相当します。
具体的に見てみましょう。
export function useNavigationContext(): NavigationContext { // implementation }
関数が first-class citizen なので signature (interface) さえ変わらなければ、
type UseNavigationContext = () => NavigationContext export const useNavigationContext: UseNavigationContext = createUseNavigationContext()
極端な例ですが、呼び出し元のコードを変えずに、後から実装を動的に差し替える形にすることも可能です。3
さて、これらのフックは Remix の useLocation
や useNavigate
に加え Jotai, XState を利用して実装しています。
このフックの実装自体が、DIP における低レベルモジュールが実装する詳細にあたります。
もちろん、アプリで定義しているこれらのフックは、単に DIP を実現するためだけに導入しているわけではありません。 あくまで、サービス固有のユースケースを実現するための機能を付加した抽象層になっています。4
ユースケースを具体的に見てみましょう。
ユーザーが一休レストランで予約をするとき、人数日時などの予約条件や空席状況に応じて、次に表示するステップを切り替えたい場面があります。 このステップ、すなわち遷移先の切り替えは、固定的なナビゲーションではなかなか実現が難しい機能です。
また、レストランの空席状況は刻々と変化するので、その時点の状況に応じた動的な画面遷移が必要となります。
このようなユースケースに対応するため、ナビゲーションロジックは XState を使ったステートマシンで定義しています。 そして、ステートマシンが次の操作に相当するイベントをもとに次の画面(状態遷移)を決定する仕組みを取りました。
useNavigationContext
はステートマシンの現在のステートとそのコンテキストを返し、useEventNavigate
はステートマシンにイベントを送信する関数を返しています。
コンポーネント設計
最後に、フロントエンドのアーキテクチャにおいて、本丸となるコンポーネントの設計について説明します。
コンポーネント、カスタムフック、Vanilla JS ロジックの三層で構成しています。
コンポーネント
コンポーネントは表示だけの責務を担う、テンプレートエンジン的な位置付けです。
function CourseFilter() { const facets = useAvailableFacets() return ( <div> {facets.map((facet) => ( <FacetButton key={facet.key} facet={facet} /> ))} </div> ) } function FacetButton({facet}: {facet: Facet}) { const toggle = useToggleFacet() const onClick = useCallback((_: MouseEvent<HTMLButtonElement>) => { toggle(facet) }, [toggle, facet]) return ( <button type="button" aria-pressed={facet.selected} onClick={onClick}> {facet.label} </button> ) }
基本的に、フックで取得した値を表示したり、イベントハンドラにバインドしている以上の仕事はしていません。
カスタムフック
カスタムフックは Vanilla JS ロジックとコンポーネントを繋ぐアダプター層です。
function useAvailableFacets() { return useAtomValue(availableFacetsAtom) } function useToggleFacet() { const set = useSetAtom(toggleFacetAtom) return useCallback((facet: Facet) => { set(facet) }, [set]) }
Jotai の atom と接続するだけの薄いアダプターになります。5
Vanilla JS ロジック
Jotai の atom から先が、Vanilla JS (TypeScript) で書かれたロジックです。
ただの TypeScript コードなので、可搬性が保証されます。
自動テスト
自動テストは、単体テストと e2e テストのみでカバーしています。
単体テストでは React Testing Library を一切使用していません。 このことからも、React への依存が最小限になっていることが伝わるかと思います。
Vanilla JS ロジックは、そのほとんどを純粋関数として実装しているので、複雑な fixture のセットアップも不要で vitest で高速にテストが可能です。
依存の最小化で得られる利点
確実に発生するシナリオとして、フレームワークのメジャーバージョンアップに伴う破壊的な変更の影響を受けにくくなる、という点が挙げられます。
React 19 や React Router v7 (fka Remix v3) でどう変わる?
現時点で判明している範囲からの判断ではありますが、影響はほぼゼロになるだろうと見ています。
直接 Remix API を呼んでいる箇所は、これまで見てきたように DIP の低レベルモジュールにあたるフックの実装内に閉じています。 また、その構造上、Remix API の呼び出し箇所も最小限に抑えられているため、仮に変更が必要になっても、手を入れないといけない箇所は必然的に最小限に留められます。
加えて Remix 自体もその設計哲学で、メジャーバージョンアップで導入される機能や変更は future flag として段階的に適用できる形で提供されます。 破壊的な変更は伴うとはいえ、バージョンアップに追随する負担が抑えられたフレームワークと言えるでしょう。
例えば Server Component もオプトイン的に必要な箇所にだけ適用できるような仕様が検討されています。
React 19 や React Router v7 についても前述したオンラインイベントでお話しする予定ですので、興味のある方はぜひご参加ください。
極端な例だが、仮に他のフレームワークに置き換えないといけなくなったら?
コンポーネント設計でお伝えしたように、薄いコンポーネント層を移植するだけで対応できます。
TypeScript で書かれたロジックは、Vanilla JS というその性質上、変更することなくそのまま使えることには説明の必要もないでしょう。
Outro
依存ライブラリやフレームワークのメジャーバージョンアップ程度では、ほとんど揺らがないアーキテクチャができました。
あらためて前提を振り返ってみると、長い歴史を持ち、継続可能性が極めて高いサービスだからこその選択であるとも言えます。 スタートアップで、MVP でとにかく試行錯誤を高速に繰り返したい、といった状況下では、また別の選択肢があると思います。
かなり長くなってしまいましたが、ここまで読んでいただきありがとうございました。 この記事が、フロントエンドのアーキテクチャを考える上での一助となれば幸いです。
一休では、本記事でお伝えしたような課題をともに解決するフロントエンドエンジニアを募集しています。
まずはカジュアル面談からお気軽にご応募ください!
- React 19 や React Router v7 のリリースが近づいています。↩
- Remix への移行を決めた理由の一つです↩
- 実際には素直に関数として実装しています。事前に定義しなくても関数の signature が interface として機能することを示すための例です。↩
- 実を言うと、最初から現在の設計に辿りつけていたわけではありません。当初は Remix の nested routes を駆使する案を検討していました。その過程でチームメンバーから示唆をもらい、ステートマシンを使ったアプローチに切り替えました。もし、当初案のままであれば DIP と正反対の状態になっていたと思います。↩
- Jotai のようなライブラリを利用しない場合は useSyncExternalStore を使って、ロジックを React と疎結合にできます。↩