🤔

【Atomic Designに懐疑的なあなたへ】改めて考えたい React / Next.js のデザインパターン

2022/05/13に公開

フロントエンド開発は一般的に複雑性との戦いです。放ったらかしにしておくとますます複雑になり、変更するのが難しくなります。これまでにも、このような複雑さをどうにかして制御しようとして、Atomic Designをはじめとした様々な設計手法(デザインパターン)が考えられてきました。

しかし、React / Next.js を使ってチーム開発を行う際に、現状のデザインパターンでの運用では「どうもうまくいかないな」と思う場面に多々遭遇しました。そのような経験を踏まえて、「コンポーネントをどのように設計するか」「どのようにディレクトリを分けるか」を徹底的に考え、新しいデザインパターン「Tree Design」にまとめました。

Tree Design はまだまだ仮説段階です。今後弊社チームで運用していく中でブラッシュアップする予定です。しかし、他のフロントエンド開発チームがデザインパターンを再考する際の一助になればと思い、ドキュメントとして公開することにしました!ぜひご一読ください。

1. 本稿について

1.1. 仮定

フロントエンド開発一般について述べるのではなく、本ドキュメントでは以下の事項を仮定して、内容を絞って詳述します:

  • 仮定1「React / Next.jsを使用します」
  • 仮定2「Reactでは関数型コンポーネントを主に使用します」
  • 仮定3「TypeScriptを使用します」
  • 仮定4「Design Systemは使用しない」

1.2. 読者の想定

1.2.1. 対象読者

  • Design Patternについて考えている方
    • コンポーネントの設計について扱います。

1.2.2. 対象でない読者

  • Rendering Patternについて考えている方
    • SSGやSSRについては本ドキュメントでは扱いません。
  • Performance Patternについて考えている方
    • SWRや画像のローディング最適化など、パフォーマンスに関することについては扱いません。

2. 現環境の整理

まず、既存のデザインパターンを振り返り、それぞれがどのようなことを解決しようとしていたのか、また、それがどの点において問題なのかについてまとめます。チーム開発のときに筆者が抱いた違和感を言語化します。

2.1. 代表的なデザインパターン

2.1.1. Atomic Design

出典:Atomic Design | Brad Frost

Atomic DesignはBrad Frost氏が提唱するデザインパターンで、コンポーネントをAtoms、Molecules、Organisms、Templates、Pagesという単位で管理します。その名前から分かる通り、Atomic Designは化学とのアナロジーでコンポーネントを整理します。

https://bradfrost.com/blog/post/atomic-web-design/

コンポーネントの最小単位はAtomsと呼ばれ、ボタンや、フォームのlabel、inputなどが該当します。それらが組み合わさってMoleculesになります。例えば、検索バーなどが該当します。Moleculesが集合すると、Organismsになり、Organismsが集合するとTemplatesになります。最後にTemplatesが集合して初めてPagesになります。詳しく知りたい方は上のブログ記事(原著)を参照してください。

2.1.2. Presentational and Container Components

https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0

Presentational and Container Componentsは主にUIを担うPresenterというコンポーネントと主にロジックを担うContainerというコンポーネントに分ける手法です。コンポーネントが必ずしもDOMを返す必要がないことを利用してUIとロジックを分離し、コンポーネントの再利用性を高めることを主な目的としています。詳しく知りたい方は上のブログ記事(原著)を参照してください。

2.1.3. Render Props

https://ja.reactjs.org/docs/render-props.html

Render Propsはpropsとしてコンポーネントをrenderする関数を渡すことで、ロジックを再利用する手法です。

<ParentComponent render={someProps => (
  <ChildComponent someProps={someProps} />
)}/>

これにより、Parent Component内のロジックを再利用することができます。

2.1.4. High Order Component (HOC)

https://ja.reactjs.org/docs/higher-order-components.html

High Order Component(高階コンポーネント、HOC)はコンポーネントを引数にとり、そのコンポーネントをラップした新しいコンポーネントを返すものです。

withSomething(WrappedComponent, config)

通常、HOCはwithFooBarの形で記述されます。これにより、ロジックを再利用することができます。

2.2. 課題

2.2.1. 既存のデザインパターンが解決しようとしたもの

Brad Frost氏は自身のブログ記事『Atomic Design』の冒頭でAtomic Designが必要になる場面について次のように述べています。

Atomic design provides a clear methodology for crafting design systems. ... Atomic design gives us the ability to traverse from abstract to concrete. Because of this, we can create systems that promote consistency and scalability while simultaneously showing things in their final context.

Atomic Designはデザインシステムを構築する明確な方法論を与えます。(中略)Atomic Designは抽象から具体に逆行することを可能にします。これのおかげで、最終局面におけるものを見せながら、同時に一貫性と拡張性を推進するシステムを作ることができます。

つまり、Atomic Designはデザイナーとディベロッパーが協調してシステム開発を行う局面において、一貫性と拡張性を高めるために発案されたものなのです。

一方、Presentational and Container ComponentsやRender Props、High Order Componentsでは、拡張性と再利用性を高めることを目的としています。関心を分離し、「因数分解」のような操作を行うことで、再利用性を高めるのです。

2.2.2. 既存のデザインパターンの問題点

一貫性と拡張性を開発にもたらすAtomic Designですが、チーム開発において少々扱いにくいと個人的に思う点が2つあります。まず、コンポーネントの粒度を決定する基準が曖昧になることです。例えば、所望のコンポーネントをOrganismsに作ったらいいのか、Moleculesに作ったらいいのか迷う場面が多々あります。確かに基準自体はあるのですが、アプリケーションの規模に関わらずコンポーネントを5つの粒度に分けるので、アプリケーションごとに基準が変わってきます。このようにAtomic Designでは明確な基準を設定する必要があるのです。

また、(私の)メンタルモデルとの不一致という難点があります。例えば、フロントエンド開発においてある機能を追加したいと考えたとき、まずUIを確認し、変更が必要な場所を特定する場合が多いです。このような際にどのコンポーネントが変更箇所に関係してくるかどうかを直感的に把握したいのですが、Atomic DesignではUIの構造とディレクトリの構造が対応していないので、開発者用ツールや拡張機能に頼る必要があります。些細なことですが、開発体験を考えるとより直感的なディレクトリ構造にしておきたいものです。

一方、Presentational and Container Componentsに関しては、発案者が古いと言っています。

Update from 2019: I wrote this article a long time ago and my views have since evolved. In particular, I don’t suggest splitting your components like this anymore. ... The main reason I found it useful was because it let me separate complex stateful logic from other aspects of the component. Hooks let me do the same thing without an arbitrary division.

2019年からの更新: だいぶ昔にこの記事を書いており、自分の考えはそれ以来進化してきました。特に、このようにコンポーネントを分けるのはこれ以上お勧めしません。(中略)私がPresentational and Container Componentsを有用と考えたのは、主に状態に関する複雑なロジックをコンポーネントの他の点から切り離すことができるようになったからです。Hooksにより恣意的に分けることなく同じことをできるようになります。

要するに、再利用性を高めるためには、Hooksを使えば良いということです。同様に、Render PropsやHigh Order Componentsに関しても、(完全ではありませんが)Hooksで解決できることが多々あります。

3. 新しいデザインパターン「Tree Design」の提案

先に述べたような課題を解決するために本稿で提案するのが「Tree Design」です。本章ではその概要から詳細までを説明します。

3.1. Goal

Tree Designの主な目的は「一貫性」、「拡張性」、「再利用性」を高め、変更に強い設計にすることです。これらは各種デザインパターンが解決しようとしてきた課題にほとんど一致します。

3.2. Strategy

3.2.1. 概要

Tree Designの主な戦略は以下の3つです。

  1. コンポーネントを予測可能なモジュールとして設計する
  2. コンポーネント間の依存関係をできるだけ排除し、再利用性を高める
  3. コンポーネントをDOMに即して整理する

まず、「コンポーネントを予測可能なモジュールとして設計する」とは、コンポーネントをできるだけシンプルな「ブラックボックス」として設計することです。例えば、コンポーネント内にだらだらとJSXを書いていると、そのコンポーネントが外から見たときに何をするのかが分かりにくいです。逆にコンポーネントが適切な粒度で設計されていると、他の開発者が見てもそのコンポーネントの挙動を把握しやすいです。(この適切な粒度については3.3.4.項で詳しく扱います。)ロジックに関しても同じことが言えます。コンポーネント内にロジックがだらだらと書かれていると他の開発者にとって読みにくいコードとなってしまいます。したがって、hooksをうまく利用して、ロジックを分離するように設計します。

次に、「コンポーネント間の依存関係をできるだけ排除し、再利用性を高める」とは、コンポーネント間のバケツリレーをできるだけ減らすことです。違う言葉で説明すると、コンポーネントを疎に設計するということです。これにはReactが提供するContext APIやReduxなどのグローバルステートパッケージが使えます。

https://ja.reactjs.org/docs/context.html

https://redux.js.org/

最後に、「コンポーネントをDOMに即して整理する」とは、ディレクトリ構造をDOMに対応させるということです。ただし、完全にDOMに対応させると、再利用可能なコンポーネントの置き場所に困るので、sharedディレクトリを作ります(詳しくは3.3.2.項)。

3.2.2. 各Nodeの中身

これらを実現するためにディレクトリを次のような木構造で設計します。

  • 各Nodeの中身
    • index.tsx:大元となるメインコンポーネント
    • elements:メインコンポーネントを構成するサブコンポーネントの集まり
    • hooks:メインコンポーネントのhooks
    • contexts:メインコンポーネントのcontexts
    • styles:メインコンポーネントのstyles
  • 子Node
    • elements配下にまたNodeができる

例えば、Headerだと以下のようなディレクトリ構造になります。

Header
├── index.tsx
├── elements
│   ├── HeaderLeft
│   │   ├── index.tsx
│   │   ├── elements
│   │   ├── hooks
│   │   ├── contexts
│   │   └── styles
│      └── HeaderRight
│       └── 略
├── hooks
│      └── useHeader.ts
├── contexts
│      └── headerContext.tsx
└── styles
       └── header.scss

3.3. Tactics

3.3.1. src配下の全体像

基本的にソースコードはsrcディレクトリ配下に含めます。src配下の構造は以下の通りです。

src
├── __test__
├── pages
│   ├── _app.tsx
│   ├── _document.tsx
│      └── something.tsx
├── components
│   ├── Something
│   │   ├── index.tsx
│   │   ├── elements
│   │   ├── hooks
│   │   ├── contexts
│   │   └── styles
│      └── shared
│       ├── elements
│       ├── hooks
│       ├── contexts
│       └── styles
├── api
│      └── index.ts
├── styles
│      └── theme.ts
└── modules 

これらを順に説明していきます。

3.3.1.1. src/pages

Next.jsでルーティングに使われる特別なフォルダです。基本的に受けとったコンポーネントを返すだけですが、ページ間で共通しているレイアウトなどを与えるのはここで行います。また、例えば、認証を加えたいページに対してHOC関数を適用する場合などにも使われます。

3.3.1.2. src/components

コンポーネントを格納するディレクトリです。基本的にsrc/components直下には各ドメイン(だいたい各ページに一致)のコンポーネントが並びます。また、再利用可能なコンポーネントについてはsharedの中で管理します(sharedについては3.3.2.項を参照)。

3.3.1.3. src/api

apiクライアントを格納するディレクトリです。GraphQLを使用する場合、それに合わせて適宜名前を変更します。

3.3.1.4. src/styles

グローバルスタイルを管理するディレクトリです。これはどのようにスタイリングするかにより、中身が大きく変わりますので、詳説を避けます。

3.3.1.5. src/modules

helper関数やパッケージごとの設定ファイルなどを雑多にmodulesに入れておきます。

3.3.2. sharedによるコンポーネントの「因数分解」

src/components/shared配下に共通するコンポーネントを格納します。その下は一般のNodeと変わりません。ただし、どうしてもelements配下が多くなってしまうので、プロジェクトの規模により分けるのも良いかもしれません。

3.3.3. コンポーネントを分ける基準

コンポーネントを子コンポーネントに分割する統一的な基準を設けるのは非常に難しいです。しかし、目安として以下のようなことが言えると思います。

  • 並列されたものに関しては分ける

例えば、Gridシステムにおいて、Gridアイテムとなるものは基本的に分けます。一方、例えば各セクションにおいてタイトルなどは分割しません。

3.3.4. elementsの例外

elements配下にコンポーネントが入っているが、hooksなど他のディレクトリにコンポーネントが入っていないときはelementsをそのまま書くことができます。

Before
Something
├── index.tsx
├── elements
│   ├── Foo
│   ├── Bar
│      └── Baz
├── hooks
│      └── (空)
├── contexts
│      └── (空)
└── styles
       └── (空)
After
Something
├── index.tsx
├── Foo
├── Bar
└── Baz

3.3.5. 命名規則

Tree Designに直接関係ないものも入れていますので、各プロジェクトでよく吟味してお使いください。

3.3.5.1. App > Page > Tab > Section

Atomic Designではコンポーネントの粒度に対して、5つの分類を行っていました。Tree Designでは、大きなコンポーネントにだけ命名規則を与えることで、一貫性を保持します。AppというのがNext.jsアプリケーションに対応します。これには_app.tsxが対応していますので、基本的に名前で迷う余地はありません。また、Pageについてもパスに対応してsrc/pages配下に作られるので、命名に困ることはほとんどありません。src/components/Something/index.tsxの次ではTabの粒度で分かれます。それを次はSectionの粒度で分解します。これは命名規則なので、必ずSomeTabやSomeSectionのようにコンポーネントの名前に反映してください。また、Tabがない場合はスキップして、Pageから直接Sectionに分割して構いません。

3.3.5.2. hooks

hooksは基本的にuseを接頭辞としてつけてください。また、useの後には確実に名詞がくるようにしてください。

x useHandle
o useHandler

また、hooks内には基本的にコンポーネントを定義しないでください。したがって、hooksのファイル拡張子は.tsxではなく.tsになります。

3.3.5.3. contexts

contextsは基本的にcamelCaseで命名します。接尾辞としてContextをつけてください。また、中でProviderコンポーネントをexportすることが多いので、基本的にはファイル拡張子は.tsxになります。

3.3.5.4. 型

基本的にコンポーネントの型にはinterfaceを使用します。これは弊社が使用しているライブラリとの親和性が良いからです。例えば、muiだと各コンポーネントのPropsのinterfaceをimportするのが可能です。もし、typeの方が親和性が高ければ、そちらを使用すると良いと思います。

命名については単にPropsとするのではなく、コンポーネント名+Propsにしてください。そうでないと、もし仮にexportしたときにrenameが必要になります。

3.3.5. デザインシステムとの親和性

弊社ではデザインシステムを採用していません。基本的にはコンポーネントライブラリを使用し、カスタマイズする際は、それらをWrapして扱います。

もし、Tree Designにデザインシステムを組み込む場合、shared配下に数多のコンポーネントが定義されることになり、大変扱いにくいです。私は、自社でデザインシステムを構築する場合、パッケージを分けるべきだと個人的に考えています。そうすることで、Tree Designの有用性を保ったままアプリケーションの複雑度を抑えることができます。

4. 最後に

ここまで、現状について軽くまとめ、新しいデザインパターン「Tree Design」を提案しました。冒頭でも述べた通り、これは仮説検証段階です。Atomic Designのアンチパターンに関する議論の出発点になれば幸いです。この記事が良かったと思った方は、ぜひいいねお願いします。また、弊社の開発に少しでもご興味を持っていただけたら、以下のリンクから弊社HPをご覧ください。

https://www.mutex-inc.dev

5. 参考文献

https://www.patterns.dev/

https://patternlab.io/

mutex Official Tech Blog

Discussion