💫

Reactベストプラクティスの宝庫!「bulletproof-react」が勉強になりすぎる件

2021/11/08に公開
3

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ディレクトリ以下に格納されています。逆に言えば、ルートディレクトリにcomponentsutilsといったディレクトリはありません。

たとえば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であればpostscommentsdirectMessagesなどが考えられます。

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直下にcomponentshookstypesなどが並び、それぞれのディレクトリ内でようやく機能別のディレクトリ名で切ることが多いのではないでしょうか。

私自身、バックエンドの実装をする際は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への依存を禁止するルールも書けそうですね。

.eslintrc.js
        'no-restricted-imports': [
          'error',
          {
            patterns: ['@/features/*/*'],
          },
        ],

https://eslint.org/docs/rules/no-restricted-imports

featureをまたいで必要になるモジュールはsrc/HOGE以下に配置する

たとえばシンプルなボタン要素など、特定のfeatureに関係なくまたいで利用されるようなコンポーネントはsrc/components以下に置きます。

例: src/components/Elements/Button/Button.tsx

providersroutesディレクトリが賢い

私がReactやReact Nativeアプリケーションを書いていると、よくApp.tsxにProviderやRouteの設定を書いてしまい行数が膨れ上がってしまうのですが、本リポジトリではprovidersroutesディレクトリを切って別で管理しているのが大変賢いと感じました。

その結果、App.tsxは大変シンプルな内容になっています。これは真似したいです。

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を別オブジェクトに切り出すことが可能になっています。

https://remix.run/blog/react-router-v6

https://github.com/remix-run/react-router/tree/main/examples/basic

本リポジトリでは(執筆時点ではβ版への依存になっているため微細な変更は今後あるかもしれませんが)以下のような実装例がすでに含まれているため予習にも使えるかなと思います。

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に集約する思想ではなく、以下の記事の思想に近い構成で管理しています。

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していると影響をモロに受けますが、合間に内製モジュールを挟むと、そこで影響範囲を抑えられる可能性が高まるわけです。現実的に全部にそれを作るのは大変ですが、手段として覚えておくと便利です。

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ライブラリがあります。個人的にも推しです。

https://react-hook-form.com/jp/

本リポジトリでは、FieldWrapperというラッパーコンポーネントを使ってRHFを組み込んでいます。FieldWrapperの中に<input>などを入れることでフォーム部品コンポーネントを実装する思想です。

FieldWrapper.tsx
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によるコンポーネント設計実践例を公開しました。

https://zenn.dev/manalink/articles/manalink-react-hook-form-v7

ここで披露した設計思想は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ですが、それを見事に活用されていてさすがの一言でした
    • もしかすると自分の設計案より再レンダリング回数減ってるかも?(未検証)

かつ、自分が設計していた思想の利点は見た感じだとすべて満たしているので、完全に上位互換かな・・・と思いました(悔しい)。

エラーハンドリングが勉強になる

Reactのエラーハンドリングはreact-error-boundaryが便利です。(もしHookとしてエラーハンドリングを使いたい人はこちらも参考になるかも)

https://github.com/bvaughn/react-error-boundary

先述した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のほうが好ましそうです。

https://blog.utaminuk.com/posts/2020/window-location-assign/

ちなみに、これはさらに個人的な見解ですが、エラーが起こったページに戻りたいかどうかでいうと微妙な気もするので、履歴に残さないlocation.replace()でもいいのではと思いました。しかしそれはそれで思わぬ挙動になってしまうとかあるのでしょうか。

その他

その他にも色々と気がついたことがあったのですが、詳しくはリポジトリのdocs以下のMarkdownを読むなりしていただくとして、ここではポイントだけ列挙していきます。

  • ソースコードのScaffoldingツールもセットアップされている
    • Scaffoldingを使うと、コマンド一発で狙ったディレクトリに決まった形式のファイルを生成できる
    • generatorsディレクトリ以下にセットアップされている
  • テストコードのセットアップも大ボリューム
    • testing-libraryも腐敗防止層としてtest/test-utils.tsを介している
    • MSWのセットアップも徹底している
      • MSW便利なのは分かるけどセットアップされたあとの状態が想像できていなかったので非常に助かる
    • GitHub Actionsにも統合済み
  • パフォーマンス考慮済み
    • 基礎的ではあるけど、RouteファイルでページコンポーネントをlazyImportしているのでCodeSplitされる
    • React.lazyはDefault Exportしか使えないのがうーんって思っていたが、なんとnamed export対応することができるらしい。知らなかった(というかなんとかしようと思ったこともなかった)
    • web-vitalsも記録することができるようにしてある
  • ESLintについて
    • import/order、過激かなと思って設定していなかったけど、いざ設定済みのを見るとたしかに見やすい気もするな...
  • React.ReactNodeは使って良いんだという安心
    • childrenの引数は全部ReactNodeにしていたけど、ReactNodeは厳密にはもっと細かい型に分類できるので厳密にしなきゃいけないかも?と気になってはいた
    • もちろんそうすべき局面もあるかもしれないが、たいていのケースにおいてはReactNodeで大丈夫っぽいことがわかってよかった
  • ネーミング
  • 全体的にライブラリの選定が好き(これは完全に主観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周りのツイートも含めよく発信しているので、よかったらフォローお願いします〜

https://twitter.com/Meijin_garden

記事が参考になったらプロテイン代(という名のバッジ)を恵んでください!

マナリンク Tech Blog

Discussion

プログラミングをするパンダプログラミングをするパンダ

Next.js+SWR の構成ですが、自分はこのレポジトリよく見てます👍
MayBeコンポーネントがなるほどなあと思いました
https://github.com/reck1ess/next-realworld-example-app

yo-nagaseyo-nagase

ご紹介いただいた記事を一通り拝見させていただきました!
ネーミングルールはそのまま社内でつかいたいとおもってます(笑)