はじめに
こんにちは、ラクスフロントエンド開発課の斉藤です。 記事タイトルはReact開発者なら知る人ぞ知るりあクト! TypeScriptで始めるつらくないReact開発のパロディです。とてもわかりやすい入門書なのでReact初学者の方には学びの第一歩として自信を持ってオススメできます!
さて今回は、モダンなフロントエンド技術を採用したうえで、極力シンプルで開発体験を損なわないようなディレクトリ構成を考えてみたので共有したく記事にしました。現在実際に運用しているのですが、今のところ大きな問題も無くチームからの不満も上がっていません。しかし、個人的に微妙な部分もあるのでそちらの紹介も行いたいと思います。
今回、構成を考えるにあたって重視したポイントは以下の3点です。
- 新しく参入するメンバーでもすぐに理解できるシンプルな構成にしたい
- テストやリファクタしやすい構成にしたい
- できればルールが厳しすぎず、快適な開発がしたい
ラクスフロントエンドチームは現在急拡大中で、新卒や中途から新しく開発に参加してくれるメンバーも多くなってきました。したがって新しく参入するメンバーでもすぐに理解できるシンプルな構成にすることはMUSTです。また、新規に開発する製品は長く開発が続いていく(はず)と見込まれるためプロジェクトの規模が大きくなっても、カオスになりにくくテストしやすい構成を目指します。
現在実際に運用しているのですが、今のところチームからの不満も上がっておらず、問題なく快適に開発できています。しかし個人的に微妙な部分もあるのでその辺りの紹介もしていければと思います。
前提として採用している主なライブラリは以下になります。
- React v18
- MaterialUI
- ReactQuery
- Recoil
- ReactHookForm
- zod
結論
src/ ├─ common/ ├─ components/ │ ├─ features/ │ │ ├─ generic/ │ │ ├─ todo/ │ │ │ ├─ container/ │ │ │ ├─ presenter/ │ │ │ ├─ hooks/ │ │ │ ├─ actions.ts │ │ │ ├─ todo.constant.ts │ │ │ ├─ todo.type.ts │ │ │ └─ todo.validation.ts │ ├─ ui/ │ └─ pages/ ├─ lib/
フロントエンドのディレクトリ構成としてまず思いつくのがアトミックデザインですが、最近はbulletproof-reactというリポジトリのアーキテクチャを参考にした機能ごとにディレクトリを分けるパターンが採用されつつあるように思います。例えば@sakitoさんが以下の記事で機能ごとにディレクトリを分ける構成を紹介しています。
アトミックデザインは明確にコンポーネントの責務を分けることができ、再利用性も高いのですが、メンバー間でコンポーネントの切り出し方に差異が生まれやすくカオスになりがちです。一方機能ごとにディレクトリを分けるパターンは、どのようにコンポーネントを切り出せばよいかがイメージしやすく、メンバー間でコンポーネントの分け方にブレが生まれにくいというメリットを感じました。したがってシンプルさという観点で軍配が上がり、今回重視するポイントにもマッチしていたので機能ごとに分けるパターンをベースとすることにしました。
依存関係は以下のようになっています。
図の上側にあるコンポーネントは下のコンポーネントimportでき、UIは他のUIを、Containerは他のContainerをimportすることができます。このような依存関係の構成は以下の@yoshikoさんの記事を参考にさせていただきました。
ui
uiディレクトリの中身は以下のようになっています。
ui/ ├─ button/ │ ├─ Button.tsx │ └─ IconButton.tsx ├─ textField/ │ ├─ TextField.tsx │ └─ RhfTextField.tsx ├─ index.ts
基本的にはMUIのコンポーネントをラップし、独自にスタイルを当てたコンポーネント群になり、ドメインを持たせないようにします。コンポーネントがドメインを持つか、持たないかは、そのコンポーネントをそのまま別のプロジェクトで使い回すことができるか、否かで判断します。またReactHookFormを使っている場合は、同じuiディレクトリ内にReactHookFormのロジックを付加したコンポーネントを配置します。
RhfTextField.tsx
import { FieldValues, useController, UseControllerProps, } from 'react-hook-form'; import { TextField, TextFieldProps } from './TextField'; export type RhfTextFieldProps<T extends FieldValues> = TextFieldProps & UseControllerProps<T>; export const RhfTextField = <T extends FieldValues>( props: RhfTextFieldProps<T> ) => { const { name, control } = props; const { field: { ref, ...rest }, fieldState: { error }, } = useController<T>({ name, control }); return ( <TextField inputRef={ref} {...rest} {...props} errorMessage={error && error.message} /> ); };
またui配下のコンポーネントはindex.tsで一気にexportしてしまいます。
components/ui/index.ts
// button export * from "./button/Button"; export * from "./button/IconButton"; // textField export * from "./textField/TextFiled"; export * from "./textField/RhfTextField";
このようにすることでコンポーネント使用側でuiパーツを一度にimportすることができます。
import { Button, RhfTextField } from "src/components/ui
features
featuresディレクトリは以下のような構成になっています。
features/ ├─ generic/ ├─ todo/ │ ├─ container/ │ ├─ presenter/ │ ├─ hooks/ │ ├─ (actions.ts) │ ├─ (todo.constant.ts) │ ├─ (todo.type.ts) │ └─ (todo.validation.ts)
features内のディレクトリは画面仕様書単位で分けます。
この運用方法のメリットは誰が開発しても同じ切り分け方になるということです。これにより、実装に入る前にメンバー間で「どのようにディレクトリを分けるか」という議論をしなくてもよくなります。また機能の開発担当も画面仕様書単位で振り分けることで、featuresディレクトリを横断して開発を行うことが少なくなりコンフリクトを防ぐことができます。
画面仕様書単位で分けると1つの機能が大きくなりすぎてしまう、という場合は画面仕様書の切り分けを先に検討します。そもそも画面仕様書の記述量が膨大だと読むコストが高く、仕様の把握漏れが頻発してしまいます。これは健全な状態ではないので先に画面仕様書の改善から行っていき、機能を小さく分けた上で開発を行います。
またContainer/Presenterパターンを採用するため、各featureディレクトリの中にcontainerディレクトリとpresetnerディレクトリを配置します。ロジックをcontainerやhooksに逃がすことでpresenterを純粋に保ち、テストしやすい構成にします。hooksには機能を実現するために必要なAPIを叩くカスタムフックや、複雑なロジックをまとめるカスタムフックを記述します。
hooks/ ├─ useFetchTodo.ts ├─ useUpdateTodo.ts ├─ useDeleteTodo.ts ├─ useToggleTodoStatus.ts
hooksがあればcontainerは不要かと思われがちですが、ロジックを全てカスタムフックに書くと容易にfatなhooksになってしまうためcontainerがあるとコードも見通しやすくなり便利です。
actions.tsにはその機能で使うRecoilのロジックを記述します。また[feature].constant.tsにはその機能で使う定数、[feature].type.tsには型、[feature].validation.tsにはzodで定義するschemeを記述します。基本的にはこれらのファイルは無くてもよく、コードの見通しが悪くなってきたら作るというユルい運用をしています。
またfeaturesにはgenericというプロジェクト内共通の機能を担うディレクトリも作成します。こちらもtodoディレクトリと同じようにcontainer、presenter、actions.tsなどを持ちます。こちらのディレクトリ内にはドメインを持ち(=他のプロジェクトには流用できない)、アプリケーション内の様々な場所で汎用的に用いられる機能のロジックを記述します。例えばヘッダに表示するユーザープロフィールコンポーネントなどはこちらに記述します。
pages
pagesはurl単位で分けfeatures内のcontainerを呼び出すだけです。
import { TodoContainer } from 'src/components/features/todo/container/TodoContainer'; export const Todo= () => { return <TodoContainer /> };
依存関係としてはrouter→pages→containerのようになっています。routerから直接containerを呼び出しても良いのですが、将来的にSuspenseやErrorBoudanryを導入したり、特定のページだけサイドバーを表示したいといった需要に対応しやすいようにこのような構成としました。
common
commonにはプロジェクト内の様々な場所で呼び出される変数や型などを定義します。
common/ ├── constants.ts ├── cssVariables │ ├── color.ts │ ├── variables.ts │ └── zIndex.ts ├── messages.ts ├── reactQueryKeys.ts ├── recoilKeys.ts ├── types.ts └── utils ├── sum.ts ├── index.ts
constants.ts
constantsの記述例
export const FONT_SIZE_MAP = { XS: "10px", S: "12px", M: "14px", L: "16px", XL: "18px" } as const export const FRUIT = { apple: "りんご", banana: "バナナ", orange: "みかん", } as const
types.ts
typesの記述例
export type Fruit = typeof FRUIT[keyof typeof FRUIT]; export type ErrorResponse = ///
cssVariables
カラーコードやz-index、ヘッダの高さなどのcssにかかわる変数を管理するディレクトリです。styled-componentを使っているのでtsファイルで管理します。
messages.ts
エラーメッセージやバリデーションメッセージを管理するファイルです。
reactQueryKeys.ts/recoilKeys.ts
ReactQueryとrecoilは使用する際に一意なキーを設定しなければならないため1つのファイル内で一元管理し、重複しないようにします。
utils
汎用的に使える便利関数を管理するディレクトリです。
lib
ライブラリの初期設定などはこちらのディレクトリに記述します。
lib/ ├── reactQueryClient.ts ├── zod.ts
改善したいポイント
features配下のディレクトリを画面仕様書単位で分けている点
画面仕様書単位でディレクトリを分けるとチーム感でのディレクトリ構成の認識齟齬が起こりづらくなるというメリットがある一方、長くプロジェクトを続けていった場合に破綻してしまうのでは無いかという不安があります。プロジェクトの初期段階では機能の数も少ないため、画面仕様書の記述量も少なくすみますが、フェーズ2、3と時が経つごとに機能が追加され、画面仕様書も更新されていきます。画面仕様書が肥大化してきてファイルを分けようとなったとき、ディレクトリも同時に分けなければルールの一貫性が損なわれてしまいます。しかし、実際にディレクトリを分割するようリファクタする工数をそのときに用意できるかわからないため、有耶無耶になってしまいルールが破綻してしまう懸念があります。
src/common配下のconstantsファイルやrecoilKeysファイルの肥大化
定数やキーは開発が進むに連れて多く書かれるためすぐに肥大化してしまいます。特にrecoilキーなどは一元管理しないとキーの値がかぶりやすくなり、安易にファイルを分け辛く悩ましいです。
constantsファイルとtypesファイルが分かれていてめんどくさい
定数から型を生成することはよくありがちなので、そのままconstantsファイルに書いてしまいたくなることがよくあります。しかしファイル名がconstantsなので型が記述されていると違和感です。constantとtypeの両方を記述するファイルにしてもいいのですが、良いファイル名が思いつきません。また、同じファイルに書いてしまうとコードが容易に肥大化してしまうので面倒ですが分けています。
まとめ
総括としてモダンフロントエンドのつらくないけどちょっとつらいディレクトリ構成に落ち着いたと感じています。完璧とは言えませんが比較的開発体験がよく、シンプルな構成にすることはできたのではないかと思います。これから運用を続けていくことで知見を貯めていき、さらなる改善を重ねていきたいです。
エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
https://career-recruit.rakus.co.jp/career_engineer/
カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
rakus.hubspotpagebuilder.com
ラクスDevelopers登録フォーム
https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/
イベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください!
◆TECH PLAY
techplay.jp
◆connpass
rakus.connpass.com