Reactアプリケーションのアーキテクチャの一例として公開されているGitHubリポジトリ「bulletproof-react」が大変勉強になるので、私自身の見解を交えつつシェアします。
https://github.com/alan2207/bulletproof-react
※2022年11月追記
記事リリースから1年ほど経過して、新しく出てきた情報や考え方を盛り込んだ続編記事を書いていただいているので、こちらも併せて読んでいただければと想います(@t_keshiさんありがとうございます!)。
https://zenn.dev/t_keshi/articles/bulletproof-react-2022
https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md
まずはプロジェクトごとにバラつきがちなディレクトリ構造について。
src
以下に入れるbulletproof-reactでは、Reactに関するソースコードはsrc
ディレクトリ以下に格納されています。逆に言えば、ルートディレクトリにcomponents
やutils
といったディレクトリはありません。
たとえばCreate Next Appで作成されるアプリケーションは、デフォルトではルートディレクトリにpages
といったソースコードのディレクトリが並びますから、src
以下に入れるのは本リポジトリが意図的に行っているディレクトリ構造といえます。
実プロジェクトのルートには、マークダウンで書かれたドキュメント群(docs
)や、GitHub Actions等のCI設定(.github
)、もしコンテナベースで扱っているアプリケーションであればDockerの設定(docker
)などが混在するでしょうから、ルートレベルに直接components
などを配置するとアプリケーションのソースコードとそうでないものが同一の階層に混在してしまいます。
単純に紛らわしいだけでなく、たとえばCI設定を書くときにもソースコードはsrc
以下に統一しておいたほうが適用する範囲を明示しやすくて便利です。
features
ディレクトリ本リポジトリにおけるディレクトリ構造で面白いなと感じた点は、features
というディレクトリです。
src|+-- assets # assets folder can contain all the static files such as images, fonts, etc.(中略)+-- features # feature based modules ← これ(中略)+-- utils # shared utility functions
features
以下には、アプリケーションが抱えている各機能の名称のディレクトリが並びます。たとえばSNSであればposts
、comments
、directMessages
などが考えられます。
src/features/awesome-feature|+-- api # exported API request declarations and api hooks related to a specific feature|+-- components # components scoped to a specific feature(中略)+-- index.ts # entry point for the feature, it should serve as the public API of the given feature and exports everything that should be used outside the feature
ディレクトリを切るときは、何基準で切るのかが重要な観点です。エンジニアの観点で切る場合、エンジニア視点でどういう役割を果たすモジュールかというディレクトリ名で切ってしまいがちです。src
直下にcomponents
、hooks
、types
などが並び、それぞれのディレクトリ内でようやく機能別のディレクトリ名で切ることが多いのではないでしょうか。
私自身、バックエンドの実装をする際はapp/Domain
というディレクトリを切り、たとえばapp/Domain/Auth
だったりapp/Domain/HogeSearch
などfeature別に切ります。そのため、フロントエンドでも同様の思想で管理するのはとても腑に落ちました。
features
ディレクトリを切ることで、機能別にcomponentやAPI、Hooksなどを管理できます。つまり、機能ごとにAPIがあるならAPI用のディレクトリを切ればいいし、無いならなくてよいというように柔軟に管理できます。また、アプリケーションを運営していると機能ごと消え去ることは日常茶飯事ですが、そういうときにfeatures
を消してしまえばよいので実装を簡単に消すことが可能です。使われていない機能がゾンビのように残り続けることほど辛いことはありませんので、素晴らしい考え方だと思いました。
加えて、これは個人的な考えなのですが、あらゆる機能を初期リリースから全力できれいな設計で実装していてはビジネス的な検証速度がおざなりになってしまいます。本リポジトリのように、features/HOGE
ごとにディレクトリ構造をある程度管理できる思想だと、初期実装時点ではcomponentsに全実装を集約してFatにしつつ、本実装〜2次リリース以降に掛けてリファクタリングして厳しい制約を課していくことも可能なのではないでしょうか。
あるファイルをfeatures
以下に置くべきかどうかは、そのfeatureが廃止されたときにともに消えるものかどうか、で判断できそうです。
ESLintルールで、features -> featuresへの依存を禁止するルールも書けそうですね。
'no-restricted-imports':['error',{patterns:['@/features/*/*'],},],
https://eslint.org/docs/rules/no-restricted-imports
src/HOGE
以下に配置するたとえばシンプルなボタン要素など、特定のfeatureに関係なくまたいで利用されるようなコンポーネントはsrc/components
以下に置きます。
例:src/components/Elements/Button/Button.tsx
providers
やroutes
ディレクトリが賢い私がReactやReact Nativeアプリケーションを書いていると、よくApp.tsx
にProviderやRouteの設定を書いてしまい行数が膨れ上がってしまうのですが、本リポジトリではproviders
やroutes
ディレクトリを切って別で管理しているのが大変賢いと感じました。
その結果、App.tsx
は大変シンプルな内容になっています。これは真似したいです。
import{ AppProvider}from'@/providers/app';import{ AppRoutes}from'@/routes';functionApp(){return(<AppProvider><AppRoutes/></AppProvider>);}exportdefault App;
React Routerのv6では<Outlet>
などの新機能を使って、routingを別オブジェクトに切り出すことが可能になっています。
https://remix.run/blog/react-router-v6
https://github.com/remix-run/react-router/tree/main/examples/basic
本リポジトリでは(執筆時点ではβ版への依存になっているため微細な変更は今後あるかもしれませんが)以下のような実装例がすでに含まれているため予習にも使えるかなと思います。
exportconst protectedRoutes=[{ path:'/app', element:<App/>, children:[{ path:'/discussions/*', element:<DiscussionsRoutes/>},{ path:'/users', element:<Users/>},{ path:'/profile', element:<Profile/>},{ path:'/', element:<Dashboard/>},{ path:'*', element:<Navigateto="."/>},],},];
私は現状、featuresに集約する思想ではなく、以下の記事の思想に近い構成で管理しています。
https://zenn.dev/yoshiko/articles/99f8047555f700
この記事で言うところのmodel
が本リポジトリにおけるfeatures
に近い考え方ですね。一般的な考え方だと、.tsx
ファイルは全部components以下に置くというのがNuxt.js等のデフォルト構成から言っても知名度が高いので、components/models
って切ってその下に機能ごとのコンポーネントを置くのは現実的には有力だと思います。
https://github.com/alan2207/bulletproof-react/blob/master/docs/components-and-styling.md
続いてはコンポーネント設計についてです。
これはいわゆる腐敗防止層ともいえるもので、私自身もすでに少しずつ取り組んでいることでもありますが、やっぱりそうするよねと思ったので記載しておきます。
以下にように、単にreact-router-domの<Link>
をラップしたコンポーネントを使っておくだけで、将来的にそのコンポーネントに破壊的な変更が入ったときに影響範囲を抑えられる可能性が高まりますね。多数のコンポーネントから直接外部ライブラリをimportしていると影響をモロに受けますが、合間に内製モジュールを挟むと、そこで影響範囲を抑えられる可能性が高まるわけです。現実的に全部にそれを作るのは大変ですが、手段として覚えておくと便利です。
importclsxfrom'clsx';import{LinkasRouterLink,LinkProps}from'react-router-dom';exportconstLink=({ className, children,...props}:LinkProps)=>{return(<RouterLinkclassName={clsx('text-indigo-600 hover:text-indigo-900', className)}{...props}>{children}</RouterLink>);};
俗に言うHeadlessコンポーネントライブラリであるHeadless UIを使ったコンポーネントが多く実装されているので、勉強になります。Headless UIはスタイルが当たっていないまたは簡単に上書きできるUIライブラリで、状態保持やアクセシビリティ等のみを責務として背負っています。昨今のReactコンポーネントはスタイリングもa11yもステートも通信も全部背負うことができるのでこうやって切り分ける思想のライブラリはとても賢いアプローチだなと思います。
ちなみに同READMEでは、大抵のアプリケーションではChakraにemotionを加えたものが一番いいんじゃない?って言っていますw(自分もコンポーネントライブラリではChakraが現状はかなり良い、次点でMUIという感じに思ってますので割と同意です)
react-hook-form(RHF)というHooks全盛期前提のFormライブラリがあります。個人的にも推しです。
https://react-hook-form.com/jp/
本リポジトリでは、FieldWrapper
というラッパーコンポーネントを使ってRHFを組み込んでいます。FieldWrapper
の中に<input>
などを入れることでフォーム部品コンポーネントを実装する思想です。
importclsxfrom'clsx';import*asReactfrom'react';import{FieldError}from'react-hook-form';typeFieldWrapperProps={ label?:string; className?:string; children:React.ReactNode; error?:FieldError|undefined; description?:string;};exporttypeFieldWrapperPassThroughProps=Omit<FieldWrapperProps,'className'|'children'>;exportconstFieldWrapper=(props:FieldWrapperProps)=>{const{ label, className, error, children}= props;return(<div><labelclassName={clsx('block text-sm font-medium text-gray-700', className)}>{label}<divclassName="mt-1">{children}</div></label>{error?.message&&(<divrole="alert"aria-label={error.message}className="text-sm font-semibold text-red-500">{error.message}</div>)}</div>);};
RHFを使った設計パターンについては以前から私も考察しており、会社のテックブログとしてRHFによるコンポーネント設計実践例を公開しました。
https://zenn.dev/manalink/articles/manalink-react-hook-form-v7
ここで披露した設計思想はView層←ロジック層←Form層という感じでレイヤを切り分ける思想でした。
一方、本リポジトリのラッパーコンポーネントを使う設計の相対的な利点について、パッと見で感じたことをリストアップします。
useController
を使わなくていいregistration={register('email')}
というようにregisterを走らせるので不要Form.tsx
を中心に型定義を頑張っているTFormValues extends Record<string, unknown> = Record<string, unknown>
みたいにextends T<unknown>
という形式でunknown
を活用するのは個人的にもよくパズルするときに使う型定義のTipsですが、それを見事に活用されていてさすがの一言でしたかつ、自分が設計していた思想の利点は見た感じだとすべて満たしているので、完全に上位互換かな・・・と思いました(悔しい)。
Reactのエラーハンドリングはreact-error-boundary
が便利です。(もしHookとしてエラーハンドリングを使いたい人はこちらも参考になるかも)
https://github.com/bvaughn/react-error-boundary
先述したAppProvider.tsx
にて利用するのが妥当かと思われます。
<ErrorBoundaryFallbackComponent={ErrorFallback}><Router>{children}</Router></ErrorBoundary>
個人的に感心したのは、フォールバック用のコンポーネントで指定されているRefreshボタンの挙動です。
<ButtonclassName="mt-4"onClick={()=>window.location.assign(window.location.origin)}> Refresh</Button>
ここのwindow.location.assign(window.location.origin)
が何をしているかというと、originに遷移なのでトップページに遷移しているわけです。ここを見て私は脳死でlocation.reload()
と書いちゃえばいいのではと思ったのですが、よくよく考えるとInvalidなクエリパラメータやページであることを原因としてエラーが起こったときに無限に落ち続けるため、わざわざボタンを置くのであればトップに戻るほうが妥当でしょう。
また、location.href =
でも同様の挙動になりますが、assignのほうがメソッド呼び出しなのでテストが書きやすいという微妙な利点があり、わずかにassignのほうが好ましそうです。
https://blog.utaminuk.com/posts/2020/window-location-assign/
ちなみに、これはさらに個人的な見解ですが、エラーが起こったページに戻りたいかどうかでいうと微妙な気もするので、履歴に残さないlocation.replace()
でもいいのではと思いました。しかしそれはそれで思わぬ挙動になってしまうとかあるのでしょうか。
その他にも色々と気がついたことがあったのですが、詳しくはリポジトリのdocs
以下のMarkdownを読むなりしていただくとして、ここではポイントだけ列挙していきます。
generators
ディレクトリ以下にセットアップされているtest/test-utils.ts
を介しているReact.lazy
はDefault Exportしか使えないのがうーんって思っていたが、なんとnamed export対応することができるらしい。知らなかった(というかなんとかしようと思ったこともなかった)import/order
、過激かなと思って設定していなかったけど、いざ設定済みのを見るとたしかに見やすい気もするな...React.ReactNode
は使って良いんだという安心ReactNode
にしていたけど、ReactNodeは厳密にはもっと細かい型に分類できるので厳密にしなきゃいけないかも?と気になってはいたreact-helmet
とかはほぼメンテ止まっててreact-helmet-async
のほうがいいはずだし、そのへんは時間見つけてコミットしようかなーと思う → 出しちゃえって思ってプルリク勢いで出した(https://github.com/alan2207/bulletproof-react/pull/45 )ここまで徹底的にあらゆるProduction Readyな設定が完了しているテンプレートリポジトリは見たことがないです。個人的にはStorybookやCypressなど、知っているだけで運用していないものが多く載っているのでブックマークとして定期的に参照したいなと思います。
他にもvercel/commerceも勉強になるなーと思うのですが、他におすすめリポジトリあれば教えて下さい!
全然自分が普段書いているReactプロジェクトでは追いついていない点が多いですが、必要性をその都度判断しつつ追従していきたいです。
TwitterでReact周りのツイートも含めよく発信しているので、よかったらフォローお願いします〜
https://twitter.com/Meijin_garden
記事が参考になったらプロテイン代(という名のバッジ)を恵んでください!
「オンライン家庭教師マナリンク」を運営する株式会社NoSchoolのエンジニアと話しませんか?
■お喋り申し込みはこちら
https://forms.gle/fGAk3vDqKv4Dg2MN7
■サービスサイト
https://manalink.jp/
https://manalink-gakuin.com/
■エンジニアリングの特徴
・全員がフロントエンド〜バックエンド(時にはインフラ)まで担当
・要求定義からビジネスメンバーと話し合う
・出社制で職種横断して密に議論
・メディア的な先生検索サイトと、SaaS的なオンライン指導ツールの両方を開発できる
・程々に枯れた技術選定
オンライン家庭教師マナリンク(manalink.jp )のCTO。好きなプログラミング言語はTypeScript、好きなHTTPヘッダーはContent-Disposition。2016年からWebエンジニア。2019年からCTO。記事を読んで技術談義したい方などいらっしゃればXでDMください
オンライン家庭教師マナリンクを運営するNoSchool社のテックブログです。manalink.jp/実際に検証・開発した内容をベースに、ただのマニュアルや告知に留まらない具体的な知見を公開します!カジュアル面談はこちら!forms.gle/fGAk3vDqKv4Dg2MN7
オンライン家庭教師マナリンク(manalink.jp )のCTO。好きなプログラミング言語はTypeScript、好きなHTTPヘッダーはContent-Disposition。2016年からWebエンジニア。2019年からCTO。記事を読んで技術談義したい方などいらっしゃればXでDMください