いい塩梅を目指したデザインエンジニアによるフロントエンド設計

こんにちは。ひらやまです。

今回は、これまでフロントエンド環境を作ったり、運用したり、設計のアドバイスをしたりしてきた私がひとまずたどり着いた、このくらいの塩梅の設計が良いのではないかと考えている一つの案をみなさんに共有しようと思います。

フロントエンド設計の必要性

フロントエンドは JSON 色づけ係と言われることもありますが、ただ JSON をきれいにしてユーザに見せる以上の難しさを感じることもあるのではないでしょうか。
実装を完遂するために必要となるスキルが広いため、様々なバックグラウンドを持つ人がコードを書くことになりやすいです。フロントエンドエンジニアと呼ばれる人も、私みたいにマークアップエンジニアからフロントエンド領域に手を伸ばした人もいれば、デザイナーやバックエンドエンジニアなどの領域からこの環境に挑戦される方もいらっしゃいます。
このような様々な背景を持つ人たちが一つのコードを書いていこうとすると、様々な意図や考えがコードに反映されることとなったり、考えられる領域をカバーしきれないままコードに反映されてしまったりするため、コードの責務が散漫し、チーム開発をしていくことが困難になることも多いのではないでしょうか。

そうなると、されどフロントエンドということでしっかり設計をしたいと思うようになるはずです。
API から JSON を受け取った上でブラウザに描画するまでを責務とするフロントエンドを、バックエンド領域と同様にレイヤー設計をしようとした場合、果たしてそれは最適なのかと不安になります。
とはいえ、不安になったところでフロントエンド領域はどのように設計したら良いのか?JS / TS 以外の HTML / CSS という概念が入ってくるこの領域にどう立ち向かうと良いのか?と頭を悩ませた方も多いのではないかと思います。

今回紹介する設計が完全に良いとは思っていませんし、いろんな文脈があっての設計だとは思いますが、何もないところから設計するよりも、この設計を批判的に捉えた上でどう設計するかを考えたほうがより洗練された設計にたどり着けるのではないかと思ったため、今回筆を取った次第です。

今回は React / Next.js をベースに話します。しかし、Vue 3 や Nuxt 2 には Composition API があるため、この設計方針にフィットする可能性があります。私自身テストまでしっかり書いたことがあるわけではありませんが、もしかしたら Vue の環境でも参考になるかもしれません。
ANDPAD 社 のフロントエンドの方が Vue 環境で関数ベースのテストを実施している記事があったため、確実性はあるのではないかと考えています。
そのため、React 環境で実装している方だけでなく Vue 環境で実装されている方もぜひ参考にしていただけると嬉しいです。

全体のディレクトリ構成

さて、本題に入ります。まず、私が一からリポジトリを立ち上げるときは以下のように設計します。

.
├── components
│   ├── helpers
│   │   └── DateFormat
│   │        ├── index.tsx
│   │        └── index.stories.tsx
│   ├── layouts
│   │   └── Default
│   │       ├── index.tsx
│   │       └── index.stories.tsx
│   ├── modals
│   │   └── FormModal
│   │       ├── index.tsx
│   │       └── index.stories.tsx
│   ├── pages
│   │   ├── About
│   │   │   ├── index.tsx
│   │   │   └── index.stories.tsx
│   │   └── Top
│   │       ├── index.tsx
│   │       └── index.stories.tsx
│   └── parts
│       ├── Footer
│       │   ├── index.tsx
│       │   └── index.stories.tsx
│       ├── Header
│       │   ├── index.tsx
│       │   └── index.stories.tsx
│       ├── Heading
│       │   ├── index.tsx
│       │   └── index.stories.tsx
│       └── SearchBox
│           ├── index.tsx
│           └── index.stories.tsx
├── containers
│   ├── layouts
│   │   └── Default
│   │       ├── hooks
│   │       │   └── useSearchBoxFields
│   │       │       ├── index.ts
│   │       │       └── index.spec.ts
│   │       ├── index.tsx
│   │       └── index.spec.tsx
│   ├── modals
│   │   └── FormModal
│   │       ├── hooks
│   │       │   ├── useFormModalSubmit
│   │       │   │  ├── index.ts
│   │       │   │  └── index.spec.ts
│   │       │   ├── index.ts
│   │       │   └── index.spec.ts
│   │       ├── index.tsx
│   │       └── index.spec.tsx
│   ├── pages
│   │   ├── About
│   │   │   ├── hooks
│   │   │   │   └── useAboutData
│   │   │   │       └── index.ts
│   │   │   │       └── index.spec.ts
│   │   │   ├── index.tsx
│   │   │   └── index.spec.tsx
│   │   └── Top
│   │       ├── hooks
│   │       │   └── useTopData
│   │       │       └── index.ts
│   │       │       └── index.spec.ts
│   │       ├── index.tsx
│   │       └── index.spec.tsx
│   └── utils
│       └── hmsToSeconds
│           ├── index.ts
│           └── index.spec.ts
├── graphql
│   └── pages
│       ├── about.gql
│       └── top.gql
├── pages
│   ├── _app.tsx
│   ├── _document.tsx
│   ├── about
│   │   └── index.tsx
│   └── index.tsx
├── types
│   ├── index.ts
│   ├── next.d.ts
│   └── svg.d.ts
└── utils
    └── typeGuards
        └── isNonNullable
            └── index.ts

上記のディレクトリ構造を見てピンと来る方がいらっしゃると思いますが、これは Container / Presentational パターンをベースにしています。独自に考えた真新しい設計方針というわけでは全くありませんので、そこはご了承いただければと思います。

説明しきれない部分も出てくると思いますが、このディレクトリ構造をベースにしてお話を進めていきます。

ビジネスロジックと表示ロジックの責務分け

この設計の幹となる依存関係

上記の数あるディレクトリの中で幹となるものは、components, containers, pages の 3 つです。 それぞれのディレクトリのファイルは以下のように記述されていきます。

// pages/index.tsx
import { TopPageContainer } from ~/containers/pages/Top

export default TopPage = () => {
  return <TopPageContainer />
}
// containers/pages/Top/index.tsx
import { Top } from ~/components/pages/Top

export TopPageContainer = () => {
  return <Top />
}
// components/pages/Top/index.tsx

export Top = () => {
  return <div>...</div>
}

Top を例に記述してみました。例を見ていただくと、各ディレクトリのファイルは pages ← containers ← components と import している状態です。

containers と components には pages というディレクトリを用意しています。このディレクトリ内には pages にある page 毎の index.tsx と 1:1 になるようにしています。
つまり、/search というページを作るのであれば、containers/pages にも components/pages にも /Search というディレクトリが生まれることになります。

この依存関係によってどのように責務分けをしていくのか、そしてどのような効果をもたらすのかを見ていきます。

components

このレイヤーには UI の表示ロジックのみが入ります。HTML / CSS による UI の作成や、ループ処理によって UI を反復的に表示する処理や、フラグを受け付けて表示形態や表示有無を制御する処理などを入れる場所です。

ここでいちばん重要なのは「ビジネスロジックと API の文脈を一切入れない」という点です。UI を表示することと API の文脈を疎結合にすることを最大限目指します。

例えば、検索結果のリストのページを作るとします。注意すべきはそのページの表示ロジックを責務とする component に、検索結果の API を叩くロジックとそのレスポンスの JSON を加工するロジック(型変換、ソートなど)を入れないということです。
表示ロジックとしては、表示に必要なデータ(タイトル、リンク先、画像 URL など)が入った Array を受け取り、それをただ表示するだけです。

つまり、components として記述されるコードは、HTML / CSS の記述が主になるということです。JavaScript としての処理は Array を受け取った場合は map 等のループ処理が入ったり、boolean を受け取ったら表示非表示の処理が入ったりするくらいのもので、言ってしまえば components こそが「JSON 色付け係」になるということです。

この責務分けを徹底すると、実は Storybook での表示が容易になります。ここに API の文脈を入れてしまうと、fetch 用の hooks を mock する必要が出てきてしまって大変です。Storybook に切り出せていれば、VRT を導入することも容易になり、表示ロジックとしても Storybook の control で mock データをブラウザ上から切り替えて、目視で確認するハードルも下がります。

Storybook がもたらす効果は Testable になるだけではありません。非エンジニアがページのデザインや表示仕様を確認するのにも使えます。
さらに、UI の実装と API の実装の並列化もしやすくなります。Storybook 上で UI だけ作っておいて、実際にリリースされる production コードからは import させないようしておくということもできるようになり、効率的な開発にも寄与できます。

また、API を REST から GraphQL に変えるということが起きたり、突如別の API からデータを取らなければならないとなったりしても、この責務分けができていれば components には一切修正を入れずにリリースすることができます。

containers

ここの領域での一番の責務は、API から fetch してきたデータを components の props の型にマッピングするという作業です。

現在の estie では GraphQL の利用を推進している状況です。なので、一つのモジュールに一つの Query を書いていくということもできますが、何もわからない状態で急に分割していくのもリスクがあるため、まずは1ページに 1Query の書き方で進めていきます(ディレクトリ構造も、graphql/pages という設計にしています)。

この containers を用いた設計は、GraphQL に限らず REST であってもそのまま使えます。様々な REST API からデータを抽出し、components にマッピングする処理を入れたい場合も、containers の責務として扱えます。
また、1モジュールに対して 1Query の設計をしたほうがパフォーマンス上の効果を期待できることは重々承知していますが、私自身その設計にまで踏み込めていないためはっきりと言及ができません。ただ一定の関心は当然あり、その草案をこの記事の「モジュールごとのローディングが作れない」の箇所に記述しています。もしよければそちらも参考にしてください。

どちらにせよ、この設計で重要な点は、API から受けとったデータを表示する責務を持つ components が要求する型に合わせるという責務をこの containers 層に持たせるということです。

さらに、そのマッピングの処理を hooks に切り出してあげる方針を取っています。理由はシンプルで Testable になるからです。
containers/pages/Top の中身を見てみると、hooks というディレクトリがあります。ここにデータをマッピングする hooks を書いていきます。さらに hooks ディレクトリ内を見ると useTopData というディレクトリがあり、その中に index.ts と index.spec.ts の2つが入っています。この index.spec.ts がユニットテストにあたります。マッピングの処理はフロント領域上でのビジネスロジックに値し、ここの実装にズレがあるとビジネス上の損失が生まれる可能性があるため、ここのテストを書いていくことはかなり重要です。

Testable にするという目的に対してはただ関数にすれば目的は達成できるため、hooks にする必要があるのかという疑問も生まれるかと思います。
しかし、そもそもhooks は use 接頭辞がついているだけの普通の関数です。
そして、hooks にしておけば useMemo や useCallback を使ってメモ化することも容易ですし、useState を使って state 管理もできるので、ひとまず hooks にしてしまって良いと考えています。

しかし、例えば Array の map 関数内などで使いたい関数を作成したい場合は hooks だと問題が起こります。そういう場合は、まずそのファイル内でしか使わないのであればそこに作成してしまい、もし汎化させたいのであれば臨機応変に functions ディレクトリや utils ディレクトリを作成すること検討するのも良いと思います。

useTopData という hook を例に、どのように記述されるのか例を示しておきます。

// containers/pages/Top/hooks/useTopData/index.ts
import { useMemo } from 'react'
import { TopQuery } from '~/graphql/__generated__/...'

export useTopData = (data: TopQuery | undefined) => {
  const items = useMemo(() => {
    if (!data) {
      return undefined;
    }

    return data.items.map((item) => {
      return ...
    })
  }, [data])

  return {
    items,
  }
}
// containers/pages/Top/hooks/useTopData/index.spec.ts
import { renderHook } from '@testing-library/react'
import { useTopData } from './'

const topQueryStubData = { ... }

describe('useTopData', () => {
  it('returns items', () => {
    const { result } = renderHook(() => useTopData(topQueryStubData))
    
    expect(result.current.items).toEqual({ ... })
  })
})

受け取ったデータを component 用にデータ加工をすることを想定しています。今回は割愛していますが、index.ts の 12 行目でマッピングをしていく想定です。

そして、そのマッピングが正しいかどうかを index.spec.ts でテストしています。表示ロジックとビジネスロジックが密結合の場合は、テストを書くときに react-testing-library で DOM を取得して確認するしかなく、ユニットテストの範囲を超えたことをしなければなりませんでしたが、components と containers に分けて、かつ hooks に切り出したことによって、ユニットテストが書けるようになりました。

上記のようにマッピング用の hooks を書き、それを実際に index.tsx でどのように利用されるかを次に記します。

// containers/pages/Top/index.tsx
import { useQuery } from 'urql'
import { TopQueryDoucment } from '~/graphql/__generated__/...'
import { Top } from ~/components/pages/Top
import { useTopData } from './hooks/useTopData'

export TopPageContainer = () => {
  const [{ data, error }] = useQuery({
    query: TopQueryDocunment
  })
  
  const { items } = useTopData(data)
  
  if (error) {
    throw error
  }
  
  return <Top items={items} />
}

※ GraphQL Client は urql を利用している想定にしていますが、別クライアントでも設計は変わりません。

上記の通り、index.tsx には HTML 要素が出てきません。ただ hooks が並び、そのあとに components に props を流すだけの関数ということになります。

また、index.tsx に対しても index.spec.tsx というのを作っています。これは結合テストにあたるものです。各 hooks からデータを作り上げ、その結果期待通りのデータが components に流れるか、はたまたどこかにリダイレクトされるのか、期待通りエラーを throw するか等をテストします。

しかし、この結合テストに関しては私としてもまだ書き方を定められてはいないため、取り急ぎ私が仮で作ったサンプルだけ提示しておきます。

nextjs-sandbox/containers/templates/Top/index.spec.tsx at main · rhirayamaaan/nextjs-sandbox · GitHub

もし何か良い書き方がありましたらご教授いただけると嬉しいです。

基本的な責務としては上記のとおりですが、この container には form ライブラリ(react-hook-form, formik など)の利用や、Context API の参照、そして state の管理等も実施します。
react-hook-form 等のフォーム系のライブラリは components で利用したほうが使いやすいというのはありますが、その UI が react-hook-form に密結合になってしまって表示ロジックの責務を超えてしまいます。
別のライブラリに差し替える未来を見据えて疎結合にする工夫を施す意味でも、なるべく処理を containers に逃がす取り組みを推奨しています。

pages

これは Next.js に依存した設計となっています。ディレクトリを切るとそのままルーティングを設定したことと同じになり、それを活用しています。
もし、Next.js を使っていない場合は routes というディレクトリを作成し、path と container の対応付けを行う責務を持った領域を作るのがよいですが、Next.js であれば、例えばトップページの場合は、pages/index.tsx で TopContainer を呼び出せばそれで終わりです。

もし、pages/askings/[id] というディレクトリを作った場合は、id を受けて、それを containers に渡すという処理だけはここに書くかもしれませんが、基本的には API とも接続されません。それくらいシンプルに、ただルーティングのためだけにある形にします。

また、ページごとに title タグや meta タグ等を変えたい場合があるかもしれませんが、それも結局は data を fetch してこないと変えられないことが多いので、それも containers に寄せる形で問題ありません。(Next.js の Headreact-helmet を使えば可能です)

Next.js 上で pages にもう一つ責務を追加するなら、それは layouts との結合の責務です。layouts とはヘッダー、フッターなどのページの共通部分を担うものです。序盤に記載したディレクトリ構成を見ていただくと、実は components にも containers にも layouts が存在しています。現状では Default という名前のものだけ置いていますが、例えば LoggedIn と Guest(未ログイン)で分けられることを考慮した設計になっています。それを pages の各ファイル内でどの layout を利用するかを決める責務を委ねることができます。
Next.js には layouts の共通 UI の再描画を抑制する方法が公式から提示されており、この設計はこのやり方にもマッチするやり方です。
また、Next v13 に登場した app ディレクトリを利用している場合は layout.tsx に container を import すればよいので、よりリーダブルになるはずです。

末端のディレクトリ設計

すでに containers の説明で取り上げていますが、例えば containers/pages/Top ディレクトリを切り抜くと以下のようになっています。

└── Top
     ├── hooks
     │   └── useTopData
     │       └── index.ts
     │       └── index.spec.ts
     ├── index.tsx
     └── index.spec.tsx

基本的に、メインの関数・Component に対してディレクトリを切り、index.{ts,tsx} を作成します。その index に対応するテストファイルがある場合は index.spec.{ts,tsx} (or index.test.{ts,tsx}) を作成します。この対応関係を常に保っていきます。

└── Top
     ├── hooks
     ├── constants.ts
     ├── types.ts
     ├── index.tsx
     └── index.spec.tsx

また、例えば Top 内の index.tsx や hooks で共通した型や定数が欲しい場合には、そのまま Top の配下に、types.ts や constants.ts を作成することもあります。

このディレクトリ設計にしておくと、どこまで共通化するかという範囲を決めやすくなります。まずは Top 内で、次は containers 内で… というように、徐々に共通化の範囲を広げられます。いきなり範囲を広げてしまうと、ユースケースが多すぎるがゆえにかえってメンテナンスがしづらくなるということは起こりやすいため、そのための配慮でもあります。
なので、例えば各ページの containers で共通で使う hooks があれば、containers/hooks を作成して入れておくのも良いですし、冒頭で示したディレクトリにもありますが、containers/utils というディレクトリも作成しており、そこにはただの関数が入っています。もし containers の utils を components 等の他の箇所でも使いたいとなれば、/utils に移動すれば良いのです。これは types でも constants でも同様です。

今度は components 側を見てみます。

└── Top
    ├── index.tsx
    └── index.stories.tsx

こちらも基本の設計は同じです。index.tsx に対して index.stories.tsx を作成して対にしていきます。もし表示ロジックが複雑になり、hooks に切り出したい場合は containers と同様に hooks ディレクトリを作成して、その中にテストファイルを作成するのも良いです。むしろテストが増えて品質が上がることに繋がります。

また、もし Top ページでのみ使うコンポーネントを別途作りたい場合は、Top の中に Component のディレクトリを切ります。例えばレコメンドを表示する UI を作成したい場合は以下のようになります。

└── Top
    ├── RecommendList
    │   ├── index.tsx
    │   └── index.stories.tsx
    ├── index.tsx
    └── index.stories.tsx

まずは Top ページ内でのみ使う UI として、このように切り出しておきます。
より広範囲にこの UI を使うことになれば、parts の方に切り出すなどして汎化していきます。
また、pages と並列に layouts, parts, helpers と切り出していますが、layouts は containers と対になるようにしたいため設計上はマストだと考えていますが、parts, helpers に関してはこの議論の範囲外としています。UI に関しては様々なディレクトリ構成の案があるため、チームに合う形を目指すと良いのではないかと考えていますが、例えば Atomic Design のように切りたいのであれば、atoms, molecules… と切っていただくのも良いかと思います。

この設計におけるモーダルの扱い方

具体的な話に入ってしまいますが、今回の設計を実施したときにうまく設計に落とし込みづらいモーダルの設計の話も合わせてします。 モーダルというと Dialog だったり Drawer だったりと色々な UI がありますが、ここでは画面の上に乗っかるものとして一括りで考えます。

まず、モーダルは page にとても類似しています。モーダルが表示されるときに fetch が走ったり、form の初期化をしたり、submit 処理があったりと、基本的には page の概念と相違ありません。

しかし、唯一違う点は、ルーティングにはひも付かないという点です。ページが表示されるトリガーは path にアクセスされたときに、対象の containers を呼び出せば良いわけですが、モーダルの場合は page の中でモーダルが呼び出されるということです。つまり page in page みたいな状態になります。この点がとても厄介です。

なので、ひとまずはモーダルも一つのページとして扱うために、container を用意します。そのため、冒頭のディレクトリ構造の containers の配下には modals というディレクトリが存在しています。
つまり、各モーダルに container を用意するという設計になっています。

基本の設計では、pages が containers を呼び出す設計になっています。しかし、ルーティングに紐づかないモーダルは container が呼び出される場所が存在しません。

しかし、もう一度しっかり考えてみると、モーダルはページ内で呼び出されるものなので、ページで必要なものを呼び出す役割を持つのは container でした。なので、ページの container でモーダルの container が呼ばれれば問題ありません。

例えば TopPage で FormModal を呼び出す場合は以下のようになります。

// containers/pages/Top/index.tsx
import { useQuery } from 'urql'
import { TopQueryDoucment } from '~/graphql/__generated__/...'
import { Top } from ~/components/pages/Top
import { FormModalContainer } from ~/containers/modals/FormModal
import { useTopData } from './hooks/useTopData'

export TopPageContainer = () => {
  const [{ data, error }] = useQuery(TopQueryDocunment)
  
  const { items } = useTopData(data)
  
  if (error) {
    throw error
  }
  
  return <>
    <Top items={items} />
    <FormModalContainer />
  </>
}

FormModalContainer 側には FormModal で使うビジネスロジックが入っています。データの fetch や submit 処理などが別途管理されています。

これでうまくいくように見えますが、実はモーダルの場合はこれだけではうまくいきません。モーダルは、基本的にはユーザが何かしらのボタンを押したときに開くものです。つまり、モーダルを開くための関数(例えば onClick)をこの TopPageContainer で作り、Top Compnent 側にその関数を渡してあげないといけません。
「FormModal を開く」という処理自体は FormModal 側が管理しなければならない処理ですが、その処理を実行するのは Top の責務となります。そのため、モーダルの場合は、そのモーダルを外の container で利用してもらうための hook を用意する形を取っています。

まずは、FormModal の container のディレクトリ構造を見てみます。

└── modals
    └── FormModal
        ├── hooks
        │   ├── useFormModalSubmit
        │   │  ├── index.ts
        │   │  └── index.spec.ts
        │   ├── index.ts
        │   └── index.spec.ts
        ├── index.tsx
        └── index.spec.tsx

containers/pages との大きな違いは、hooks 配下に index.ts が存在している点です。これは pages にはないファイルです。このファイルの中に「FormModal を開く」ための関数などの処理を入れておき、これを利用する container で叩いてもらうという形を取ります。

// containers/modals/FormModal/hooks/index.ts
import { useCallback, useState } from 'react'

export const useFormModal = () => {
  const [isDisplayed, setIsDisplayed] = useState(false)
  
  const openModal = useCallback(() => {
    setIsDisplayed(true)
  }, [])
  
  return {
    openModal,
    isDisplayed,
  }
}

そして、以下のように TopContainer 側で利用します。

// containers/pages/Top/index.tsx
import { useQuery } from 'urql'
import { TopQueryDoucment } from '~/graphql/__generated__/...'
import { Top } from ~/components/pages/Top
import { FormModalContainer } from ~/containers/modals/FormModal
import { useFormModal } from ~/containers/modals/FormModal/hooks
import { useTopData } from './hooks/useTopData'

export TopPageContainer = () => {
  const [{ data, error }] = useQuery(TopQueryDocunment)
  
  const { items } = useTopData(data)
  
  const { openModal, isDisplayed } = useFormModal();
  
  if (error) {
    throw error
  }
  
  return <>
    <Top items={items} onModalButtonClick={openModal} />
    <FormModalContainer isDisplayed={isDisplayed} />
  </>
}

isDisplayed の管理は、実際には contexts で管理すると良いとは思いますが今回は割愛しており、そのため props で受け付ける想定にしています。 また、openModal の関数の中には、実際には form の initialValues をセットする処理を入れることもできます。

この設計のデメリット

完璧な設計というものはなく、この設計にもデメリットがあります。

コードを書くときの直感性が失われる

例えば、react-hook-form を使うときに、containers で全て関数等を用意してから components に渡すため、react-hook-form によって楽に書ける記法が使えないということがあります。

例えば、useForm から取得できる register 関数を使いたい場合、components には register という文脈は入れたくないため、register 関数の型を自分で作らないといけません。また、その UI のコードだけ見た場合に、react-hook-form の文脈が無いからこそ、なんで関数を受け取っているんだ?という疑問を抱かざるを得ません。なので、速度を出したい場合は react-hook-form と密依存させることを許容してしまって、container の責務を緩めるということも必要かもしれません。

モジュールごとのローディングが作れない

基本的に1つのページに必要なデータを fetch し、それを components にデータを投げるという設計をしているため、ページの一箇所だけ差分ローディングさせたいという場合に、今の場合だと components に fetch の処理を入れないとうまくいきません。

とはいえ、モーダルに関していえばモーダルに必要なデータをページを呼び出した段階で fetch するという設計からは脱せています。また、ログイン情報をページごとに fetch するというのも layouts の存在によって回避できています。なのでその2点を考慮できていれば良いという場合は、この設計で十分なはずです。

しかし、せっかく React v18 によって Suspense が安定版になったにもかかわらず、モジュールごとのローディングを検討してパフォーマンス改善を目指そうとしていた場合など、一定のケースで思い悩むこともあると思います。

そのため、私はまだ実施したことがありませんが、以下のような設計を考えてみました。もしよければ参考にしてみていただけると嬉しいです。

.
├ components
│ ├ layouts
│ │ └ Default
│ │   ├ containers
│ │   │ ├ hooks
│ │   │ └ index.tsx
│ │   └ presenters
│ │     ├ index.tsx
│ │     └ index.stories.tsx
│ ├ modals
│ │ └ FormModal
│ │   ├ containers
│ │   │ ├ hooks
│ │   │ └ index.tsx
│ │   └ presenters
│ │     ├ index.tsx
│ │     └ index.stories.tsx
│ ├ modules
│ │ ├ Header
│ │ │ └ presenters
│ │ │   ├ index.tsx
│ │ │   └ index.stories.tsx
│ │ ├ Footer
│ │ │ └ presenters
│ │ │   ├ index.tsx
│ │ │   └ index.stories.tsx
│ │ └ RecommendList
│ │   ├ containers
│ │   │ ├ hooks
│ │   │ └ index.tsx
│ │   └ presenters
│ │     ├ index.tsx
│ │     └ index.stories.tsx
│ └ pages
│   └ Top
│     ├ containers
│     │ ├ ItemList
│     │ │ ├ hooks
│     │ │ └ index.tsx
│     │ ├ hooks
│     │ └ index.tsx
│     └ presenters
│       ├ ItemList
│       │ ├ index.tsx
│       │ └ index.stories.tsx
│       ├ index.tsx
│       └ index.stories.tsx
└ pages
  ├ _app.tsx
  ├ _documents.tsx
  └ index.tsx

この設計にした場合、API の fetch が必要になったタイミングで container を作成するイメージです。
Top でしか使わないけれどモジュール単位で API と繋げたい場合も、components/pages/Top 配下で containers と presenters で分けることで可能になっています。
(presenters という名にした理由は、components in components に違和感があったからですが、components のままでも良いと思います。)

また、import の依存関係の整理は議論が必要かもしれません。利用するときには必ず containers を用意する形で機械的に制限をかけるのか、それとも presenters の import を許容するのかは好みが分かれそうです。

ちなみに、余談ではありますが、ページごとの fetch で良いけれど Suspense を使ってみたいというケースであれば、実は今回提示した設計でもうまくいきます。

// containers/pages/Top/index.tsx
import { Suspense } from 'react'
import { useQuery } from 'urql'
import { TopQueryDoucment } from '~/graphql/__generated__/...'
import { Top } from ~/components/pages/Top
import { PageLoading } from ~/components/parts/PageLoading
import { useTopData } from './hooks/useTopData'

const Container = () => {
  const [{ data, error }] = useQuery({
    query: TopQueryDocunment,
    context: {
      suspense: true,
    }
  })
  
  const { items } = useTopData(data)
  
  if (error) {
    throw error
  }
  
  return <Top items={items} />
}

export const TopPageContainer = () => {
  return (
    <Suspense fallback={<PageLoading />}>
      <Container />
    </Suspense>
  )
}

1ファイルに1コンポーネントしか定義したくないという場合はファイルの設計を少し考え直す必要があるかもしれませんが、取り急ぎ上記のようにすれば Suspense を活用しつつ container の設計を共存させることが可能です。

モーダルが分かりづらい

モーダルのところで色々と説明しましたが、このモーダルの仕組みはすぐに理解しづらいものになっていると思っています。そのため、モーダルの UI を作成するときに都度頭を悩ませる状態であり、実装者フレンドリーではないと思っています。 そのため、モーダルを作るときにもう少し楽になるような共通 Component や hooks、関数があると楽になるだろうとは思いつつ、まだ答えに辿り着けていません。

さいごに

この設計を導入し、さらにナレッジワーク社が作成している eslint-plugin-strict-dependencies を入れると、さらにメンテナンス性が上がるはずです。

ディレクトリ単位で import できる領域を制限できると、components で containers を import するなど、コードを意図しない使われ方を防げるようになるはずです。

コードを使い回すということに慎重になりつつ、page を作るくらいであればどこに何を書くかをそこまで考えなくて良い設計で、かつテストもし易い状態になっているのではないかと考えています。

また、この設計にたどり着いたのは、私一人の力では全くなく、estie はもちろんのこと、私が今まで関わってきた企業の方々との対話があってこその設計です。
私はもともと HTML / CSS / jQuery しか書けませんでしたが、企業に所属する中で、React Hooks に変遷する過渡期にしがみつきながらビジネスロジックと表示ロジックを分けることを学び、さらにビジネスロジックに対するテストの書き方と GraphQL 環境における設計の仕方を学べました。また、Vue 環境に触れたり、副業にてアドバイザリー業務を担当した企業様とともに私の設計をベースにしながら議論を重ねて設計を固めていくプロセスを経たことで更に考えが深化されました。
そして今、estie にてこのフロントエンドの設計を展開したところ、ありがたいことに社内で評判がよく、一部のプロジェクトではこの設計に置き換える作業が進行中です。その過程でも「こうしたほうがいい」という議論は生まれており、より良い設計に向けて日々改善しているところです。

設計というと議論が広がりすぎてしまい、多様な意見が飛び交うものではあるかと思いますが、一つの案として参考にしていただけると嬉しいです。

もし、フロントエンドに関心がある方がいましたら、estie では SWE とデザインエンジニアを積極採用中ですので、ぜひご連絡ください!

hrmos.co

hrmos.co

© 2019- estie, inc.