Reactベストプラクティスの宝庫!「bulletproof-react」が勉強になりすぎる件
Reactアプリケーションのアーキテクチャの一例として公開されているGitHubリポジトリ「bulletproof-react」が大変勉強になるので、私自身の見解を交えつつシェアします。
※2022年11月追記
記事リリースから1年ほど経過して、新しく出てきた情報や考え方を盛り込んだ続編記事を書いていただいているので、こちらも併せて読んでいただければと想います(@t_keshiさんありがとうございます!)。
ディレクトリ構造が勉強になる
まずはプロジェクトごとにバラつきがちなディレクトリ構造について。
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/*/*'],
},
],
src/HOGE
以下に配置する
featureをまたいで必要になるモジュールはたとえばシンプルなボタン要素など、特定の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';
function App() {
return (
<AppProvider>
<AppRoutes />
</AppProvider>
);
}
export default App;
react-router@v6前提の実装にすでに対応している
React Routerのv6では<Outlet>
などの新機能を使って、routingを別オブジェクトに切り出すことが可能になっています。
本リポジトリでは(執筆時点ではβ版への依存になっているため微細な変更は今後あるかもしれませんが)以下のような実装例がすでに含まれているため予習にも使えるかなと思います。
export const protectedRoutes = [
{
path: '/app',
element: <App />,
children: [
{ path: '/discussions/*', element: <DiscussionsRoutes /> },
{ path: '/users', element: <Users /> },
{ path: '/profile', element: <Profile /> },
{ path: '/', element: <Dashboard /> },
{ path: '*', element: <Navigate to="." /> },
],
},
];
【補足】その他のディレクトリ構成の例
私は現状、featuresに集約する思想ではなく、以下の記事の思想に近い構成で管理しています。
この記事で言うところのmodel
が本リポジトリにおけるfeatures
に近い考え方ですね。一般的な考え方だと、.tsx
ファイルは全部components以下に置くというのがNuxt.js等のデフォルト構成から言っても知名度が高いので、components/models
って切ってその下に機能ごとのコンポーネントを置くのは現実的には有力だと思います。
コンポーネント設計が勉強になる
続いてはコンポーネント設計についてです。
外部ライブラリのコンポーネントをラップしたコンポーネントを内製する
これはいわゆる腐敗防止層ともいえるもので、私自身もすでに少しずつ取り組んでいることでもありますが、やっぱりそうするよねと思ったので記載しておきます。
以下にように、単にreact-router-domの<Link>
をラップしたコンポーネントを使っておくだけで、将来的にそのコンポーネントに破壊的な変更が入ったときに影響範囲を抑えられる可能性が高まりますね。多数のコンポーネントから直接外部ライブラリをimportしていると影響をモロに受けますが、合間に内製モジュールを挟むと、そこで影響範囲を抑えられる可能性が高まるわけです。現実的に全部にそれを作るのは大変ですが、手段として覚えておくと便利です。
import clsx from 'clsx';
import { Link as RouterLink, LinkProps } from 'react-router-dom';
export const Link = ({ className, children, ...props }: LinkProps) => {
return (
<RouterLink className={clsx('text-indigo-600 hover:text-indigo-900', className)} {...props}>
{children}
</RouterLink>
);
};
Headlessコンポーネントライブラリを用いた実装例が多数ある
俗に言うHeadlessコンポーネントライブラリであるHeadless UIを使ったコンポーネントが多く実装されているので、勉強になります。Headless UIはスタイルが当たっていないまたは簡単に上書きできるUIライブラリで、状態保持やアクセシビリティ等のみを責務として背負っています。昨今のReactコンポーネントはスタイリングもa11yもステートも通信も全部背負うことができるのでこうやって切り分ける思想のライブラリはとても賢いアプローチだなと思います。
ちなみに同READMEでは、大抵のアプリケーションではChakraにemotionを加えたものが一番いいんじゃない?って言っていますw(自分もコンポーネントライブラリではChakraが現状はかなり良い、次点でMUIという感じに思ってますので割と同意です)
react-hook-formを使った設計例が勉強になる
react-hook-form(RHF)というHooks全盛期前提のFormライブラリがあります。個人的にも推しです。
本リポジトリでは、FieldWrapper
というラッパーコンポーネントを使ってRHFを組み込んでいます。FieldWrapper
の中に<input>
などを入れることでフォーム部品コンポーネントを実装する思想です。
import clsx from 'clsx';
import * as React from 'react';
import { FieldError } from 'react-hook-form';
type FieldWrapperProps = {
label?: string;
className?: string;
children: React.ReactNode;
error?: FieldError | undefined;
description?: string;
};
export type FieldWrapperPassThroughProps = Omit<FieldWrapperProps, 'className' | 'children'>;
export const FieldWrapper = (props: FieldWrapperProps) => {
const { label, className, error, children } = props;
return (
<div>
<label className={clsx('block text-sm font-medium text-gray-700', className)}>
{label}
<div className="mt-1">{children}</div>
</label>
{error?.message && (
<div role="alert" aria-label={error.message} className="text-sm font-semibold text-red-500">
{error.message}
</div>
)}
</div>
);
};
RHFを使った設計パターンについては以前から私も考察しており、会社のテックブログとしてRHFによるコンポーネント設計実践例を公開しました。
ここで披露した設計思想はView層←ロジック層←Form層という感じでレイヤを切り分ける思想でした。
一方、本リポジトリのラッパーコンポーネントを使う設計の相対的な利点について、パッと見で感じたことをリストアップします。
- フォーム部品に共通で存在するべきラベルとエラー表示を共通化できる
- 私の設計だと、View層かForm層でラベルとエラー表示を担うので、共通化できていない。都度実装する必要がある
-
useController
を使わなくていい- Form層にて
registration={register('email')}
というようにregisterを走らせるので不要- 加えてちゃんとregisterメソッドの引数の文字列が型安全になっている
- ここを型安全にするために
Form.tsx
を中心に型定義を頑張っている - たとえばHOCとしてView層をラップする設計思想を採用したことがあるけど一部anyを当てないと上手く型定義できなかった
-
TFormValues extends Record<string, unknown> = Record<string, unknown>
みたいにextends T<unknown>
という形式でunknown
を活用するのは個人的にもよくパズルするときに使う型定義のTipsですが、それを見事に活用されていてさすがの一言でした
- ここを型安全にするために
- 加えてちゃんとregisterメソッドの引数の文字列が型安全になっている
- もしかすると自分の設計案より再レンダリング回数減ってるかも?(未検証)
- Form層にて
かつ、自分が設計していた思想の利点は見た感じだとすべて満たしているので、完全に上位互換かな・・・と思いました(悔しい)。
エラーハンドリングが勉強になる
Reactのエラーハンドリングはreact-error-boundary
が便利です。(もしHookとしてエラーハンドリングを使いたい人はこちらも参考になるかも)
先述したAppProvider.tsx
にて利用するのが妥当かと思われます。
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Router>{children}</Router>
</ErrorBoundary>
個人的に感心したのは、フォールバック用のコンポーネントで指定されているRefreshボタンの挙動です。
<Button className="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のほうが好ましそうです。
ちなみに、これはさらに個人的な見解ですが、エラーが起こったページに戻りたいかどうかでいうと微妙な気もするので、履歴に残さないlocation.replace()
でもいいのではと思いました。しかしそれはそれで思わぬ挙動になってしまうとかあるのでしょうか。
その他
その他にも色々と気がついたことがあったのですが、詳しくはリポジトリのdocs
以下のMarkdownを読むなりしていただくとして、ここではポイントだけ列挙していきます。
- ソースコードのScaffoldingツールもセットアップされている
- Scaffoldingを使うと、コマンド一発で狙ったディレクトリに決まった形式のファイルを生成できる
-
generators
ディレクトリ以下にセットアップされている- ディレクトリ構造が安定しているからできること
- https://www.npmjs.com/package/plop が使われています
- ちなみに私はマークダウンドリブンで書けるScaffdogが好き
- テストコードのセットアップも大ボリューム
- testing-libraryも腐敗防止層として
test/test-utils.ts
を介している - MSWのセットアップも徹底している
- MSW便利なのは分かるけどセットアップされたあとの状態が想像できていなかったので非常に助かる
- GitHub Actionsにも統合済み
- testing-libraryも腐敗防止層として
- パフォーマンス考慮済み
- 基礎的ではあるけど、RouteファイルでページコンポーネントをlazyImportしているのでCodeSplitされる
-
React.lazy
はDefault Exportしか使えないのがうーんって思っていたが、なんとnamed export対応することができるらしい。知らなかった(というかなんとかしようと思ったこともなかった) - web-vitalsも記録することができるようにしてある
- ESLintについて
-
import/order
、過激かなと思って設定していなかったけど、いざ設定済みのを見るとたしかに見やすい気もするな...
-
-
React.ReactNode
は使って良いんだという安心- childrenの引数は全部
ReactNode
にしていたけど、ReactNodeは厳密にはもっと細かい型に分類できるので厳密にしなきゃいけないかも?と気になってはいた - もちろんそうすべき局面もあるかもしれないが、たいていのケースにおいてはReactNodeで大丈夫っぽいことがわかってよかった
- childrenの引数は全部
- ネーミング
- https://github.com/kettanaito/naming-cheatsheet こんなリポジトリがあるのは初耳。社内README代わりにできそう。
- 全体的にライブラリの選定が好き(これは完全に主観w
- tailwind
- react-hook-form
- msw
- testing-library
- clsx
- 一方で、
react-helmet
とかはほぼメンテ止まっててreact-helmet-async
のほうがいいはずだし、そのへんは時間見つけてコミットしようかなーと思う → 出しちゃえって思ってプルリク勢いで出した(https://github.com/alan2207/bulletproof-react/pull/45 )
まとめ
ここまで徹底的にあらゆるProduction Readyな設定が完了しているテンプレートリポジトリは見たことがないです。個人的にはStorybookやCypressなど、知っているだけで運用していないものが多く載っているのでブックマークとして定期的に参照したいなと思います。
他にもvercel/commerceも勉強になるなーと思うのですが、他におすすめリポジトリあれば教えて下さい!
全然自分が普段書いているReactプロジェクトでは追いついていない点が多いですが、必要性をその都度判断しつつ追従していきたいです。
告知
TwitterでReact周りのツイートも含めよく発信しているので、よかったらフォローお願いします〜
記事が参考になったらプロテイン代(という名のバッジ)を恵んでください!
オンライン家庭教師マナリンクを運営するスタートアップNoSchoolのテックブログです。 manalink.jp/ 創業以来年次200%前後で売上成長しつつ、技術面・組織面での課題に日々向き合っています。 カジュアル面談はこちら! forms.gle/fGAk3vDqKv4Dg2MN7
Discussion
Next.js+SWR の構成ですが、自分はこのレポジトリよく見てます👍
MayBeコンポーネントがなるほどなあと思いました
コメントありがとうございます!僕もSWRユーザーなので勉強になりそうです。
このコンポーネントですね!{ hogeFlag && <div>hoge</div> }みたいなのがきれいに書けるのがメリットというところでしょうか
ご紹介いただいた記事を一通り拝見させていただきました!
ネーミングルールはそのまま社内でつかいたいとおもってます(笑)