Next.jsを利用したWebアプリケーションにおけるディレクトリ構成や実装方針
いくつかのプロジェクトでNext.jsを用いたWebアプリケーションの開発に携わり、概ね上手く機能する基本的な構成が見えてきたため、取りまとめて紹介します。
Next.js 13から導入された、App Routerを利用する前提で紹介しますが、基本的な考え方は従来のPages Routerでも利用できるはずです。
なお、今回紹介する考えは、基本的にユーザがログインして利用するWebアプリケーションを開発するためのものです。オウンドメディアやECサイトなど、SEOや速度などが重視されるサイトやアプリケーションでは、異なるアプローチが必要となるでしょう。
基本設定
create-next-app
が提供する設定をそのまま利用することを推奨します。
特にBabel関連の設定を変更する場合は慎重になるべきです。
Next.js 12からSWCを利用したコンパイラが提供されていますが、Babelへのプラグインの追加に代表されるような設定変更が行われている(アプリケーションに .babelrc
ファイルがある)場合は、自動的にBabelを利用した変換が行われます。
Babelへのフォールバックはコンパイル速度の低下だけでなく、Jestの設定にも影響します。Next.jsはJsetの設定を容易にする機能を提供していますが、この機能はSWCの利用が前提となっています。そのため、Babelの設定を変更する場合は、手動でJestの設定を行うことが必要になるでしょう。
このBabelプラグインの利用に対する制約は、実装面では特にCSS-in-JSの利用に対して大きな影響を与えます。Next.jsはまだServer ComponentsでのCSS-in-JSをサポートしていないことも踏まえ、特段の理由が存在しない限り、Next.jsが述べるようにCSS ModulesやTailwinid CSSを用いたスタイリングを採用することを推奨します。
逆にTypeScript / JavaScript以外のリソースについては、問題なくWebpackプラグインを利用することができます。
svgファイルをReactコンポーネントに変換するSVGRなどは、公式でNext.jsへの追加方法を公開しています。状態や配置によってアイコンのサイズやカラーを変更する機会の多いWebアプリケーション開発では、導入しておくことをお勧めします。
ディレクトリ構成
基本的なディレクトリ構成は以下となります。
root
├─ public
└─ src
├─ app
├─ interfaces
├─ queries
├─ contexts
├─ hooks
│ └─ api
├─ components
│ └─ layouts
├─ templates
├─ organisms
├─ molecules
└─ atoms
設定などのためのファイルやディレクトリと混ざることがないように、 src
ディレクトリにアプリケーションコードを配置するのが私の好みです。Next.jsは src
ディレクトリにアプリケーションコードを配置するスタイルをサポートしています。
templates
や organisms
、 molecules
、 atoms
はAtomic Designから名称を借用していますが、それぞれ本来の意味とは異なっています。適切な命名があれば良いのですが、思いつかないためこれらの名称を利用しています。
また、Pages Routerを利用した場合の命名をそのまま引き継いでいるため、一部App Routerのルーティング定義に用いるファイル名と重複しているものがあります。アプリケーションの動作に影響はありませんが、その役割は異なるため留意してください。
大規模なアプリケーションにおける機能ごとの分割について
上記のディレクトリ構成は、責務に基づいて分割したものです。
多くの機能を持つ大規模なアプリケーションでは、次のように機能ごとにディレクトリを作成し、その配下に同様の構成をとったほうが良い場合もあるでしょう。
root
├─ public
└─ src
├─ app
├─ features
│ ├─ [feature-a]
│ └─ [feature-b]
└─ shared
しかし、開発の初期段階から機能ごとのディレクトリに分割することはあまりお勧めしません。
開発初期の段階では、機能間のつながりが明確でないことが主な理由です。
ユーザのフィードバックなどから、ある機能を利用しているときに、別の機能を呼び出したいという要求は頻繁に発生します。
それを実現するために、機能をまたぐ依存を許容すると機能間の相互依存が発生し、好ましくありません。相互依存を防ぐために機能をまたぐ依存を許容しない場合、各機能で同じ実装が行われたり、sharedが無秩序に肥大化するといった状況が予測されます。
明らかに関連を持たないと判断できる、もしくは機能ごとに担当チームを分けるといった明確な理由があれば別ですが、最初は責務に応じた方法(例えば templates
ではルーティング、 organisms
では扱うデータの種類)で分割し、必要性が生じた段階で機能ごとに整理するという進め方をお勧めします。
なお、App Routerであっても特定のディレクトリにのみ固有のグローバルスタイルを定義することはできません。(正確には app
ディレクトリ以下のどこでもグローバルスタイルをインポートすることはできますが、読み込まれたスタイルは破棄されない)
そのため、特定の機能のみ異なるUIフレームワークを用いるといったことは難しく、そのような場合は別のアプリケーションとして実装したほうが良いでしょう。
app
ディレクトリ
- App Routerの規約に基づくルーティングの定義を行います
- ルーティングに関連するディレクトリ / ファイルのみを配置することを基本とします
Next.jsは
page.tsx
が配置されたディレクトリのみをルーティングの対象とする機能を提供していますが、ルーティング定義を明確にするためです
- クエリストリングのチェックなどルーティングに関連する処理をファイルとして切り出す場合は、プライベートフォルダに配置します
_folder-name
のようにアンダースコアをディレクトリ名の前に付与するとプライベートフォルダとして扱われます。
Next.jsのファイル規則との将来的な名前の競合を回避するためです。
- 共通のUIコンポーネントを配置することを目的として、
layout.tsx
やルートグループを用いません
Webアプリケーションではリソースの作成画面ではグローバルナビゲーションではなく、固有のナビゲーションを表示するといった状況が多く発生します。そのようなナビゲーションの表示制御のためにルートグループを用いると、ルーティング定義の把握が難しくなります。
画面に適用するナビゲーションの選択はTemplateコンポーネントの責務となります。
ナビゲーションの状態を維持するために layout.tsx
にコンテクストプロバイダを配置すること、またコンテクストプロバイダを整理することを目的としたルートグループの利用を妨げるわけではありません。
-
queries
ディレクトリで定義されたデータを取得する関数を利用するServer Componentは、app
ディレクトリの適切な場所に_components
ディレクトリを作成して配置します -
app
ディレクトリ以外にはServer Componentを配置しません
ClientComponentはServer Componentを直接インポートすることができないため、Server Componentを明確に区別するためです。
インタラクティブな操作が行われるWebアプリケーションでは、Client Componentがコンポーネントが主となります。
ログインしたユーザによって提供するデータが異なり、ユーザによるデータの更新が発生するアプリケーションでは、Next.jsによるデータキャッシュの恩恵が少なく、アクセス制御や更新時の処理のコストが大きいと考えます。
interfaces
ディレクトリ
- アプリケーションで扱うモデルのインタフェースを定義します
- クラスではなく、インタフェースを基本とします
永続化されたアプリケーション全体のデータをフロントエンドとバックエンドで共有することは現実的ではないため、メソッドを含めたモデルを共有することは困難です。
APIやlocalStorageなどとのやりとりはJSONを用いる必要がありますが、特に継承を持つクラスのデシリアライズは煩雑な処理が必要となります。
グラフィック処理など、クラスを用いることが適切な場面での利用を妨げるわけではありません。
- 型ガード関数もこのディレクトリに配置します
-
as const
による定数の定義とそれに基づくUnion型の生成なども、このディレクトリに配置します
API定義に基づくインタフェースの生成
プロジェクトの体制にはよりますが、APIを最初に定義して、フロントエンド、バックエンドともにその定義に対して実装する方式を推奨します。
GraphQLであればスキーマ定義やクエリから、TypeScriptの型定義を生成することは容易です。
従来のWeb APIであっても、OpenAPIで記述することで型定義やAPIを呼び出すクライアントを生成することができます。
OpenAPIについて、特に制約がなければ@himenon/openapi-typescript-code-generatorを採用します。
設計コンセプトにある、「型定義に実体が含まれないこと」と「どの API クライアントライブラリにも依存しないこと」が採用する主な理由です。
注入するAPIクライアントを実装する必要はありますが、生成されたコードによってアプリケーションの実装や他のライブラリの選定が制限されることがありません。
API定義に基づいて生成された型定義は、あくまでもフロントエンドとバックエンドのインタフェースに対するものです。
生成された型定義をフロントエンドのコンポーネントやロジックが直接参照することは、APIとそれらが密結合となることを意味するため、行うべきではないと考えます。
フロントエンドは interfaces
ディレクトリで型定義で行い、コンポーネントやロジックはそれのみに依存します。
API定義に基づいて生成された型定義と、フロントエンドを実装する上で導かれる型定義はもちろん多くの場合一致するため、フロントエンドの型定義を提供するコードが、生成された型定義をそのままエクスポートすることは許容されます。
queries
ディレクトリ
- Server Componentが利用するデータ取得の関数を配置します
アプリケーション全体で共通するマスターデータなど、全てのユーザが同じ値を利用し、頻繁な更新が発生しないデータの取得を対象とします。
サーバサイドで行われるデータ取得を把握しやすくすることが、独立したディレクトリにまとめる目的です。
- キャッシュの制御も関数の責務に含まれます
- あまりないことだと思いますが、同じデータを取得する関数であってもライフサイクル(再検証のタイミング)が異なる場合は別の関数として定義し、適切な命名を行います
fetchによるAPI呼び出しであれば、適切な再検証の間隔やオンデマンド再検証のためのタグを設定します。
データベースからの取得の場合は、Reactの
cache
関数でラップしたものを公開します。
cache
関数を用いたデータの再検証は、revalidatePath
を用いる必要があります。
-
queries
ディレクトリの関数は、app
ディレクトリ以外の場所から利用してはいけません
APIやデータベースなどのデータソースの認証情報がクライアントに公開されることを防ぐためです。
contexts
ディレクトリ
- アプリケーション全体や、ページを横断する状態を共有するためのコンテクストを定義します
- 認証状態や、ユーザが選択している言語やロケールなど、アプリケーション全体で利用する状態を管理するコンテクストなどが想定されます
- ユーザの入力したデータを複数のページに渡って共有するためのコンテクストなども、このディレクトリに配置します
逆に局所的なコンポーネント間でのデータの共有や、機能を利用するためのコンテクストはこのディレクトリには配置しません。
データの共有を行うコンポーネントや機能を提供するコンポーネントの内部に配置します。
状態管理ライブラリの利用
ReduxやRecoilなどReactには状態管理を目的とするライブラリがいくつかありますが、私は開発の初期段階でそれらを導入することはほとんどありません。
フロントエンドで管理すべき状態や値は次の3つに大別されます
- URLのパスで示されるロケーション
- API呼び出しのレスポンス
- ユーザの操作による状態の変更や入力データ
1番目のURLのパスはどのページを表示するかを決定するものです。Next.jsではApp RouterやPages Routerによって暗黙的に行われ、必要であればパスやクエリパラメータにアクセスするための機能が提供されています。
2番目のAPI呼び出しのレスポンスについては hooks
ディレクトリの項目で詳しく述べますが、SWRや@tanstack/react-queryといったライブラリで管理することをお勧めします。
最後の3番目のユーザの操作による状態の変更についてですが、まずその状態をブラウザのリロードや保存されたブックマークなどからアクセスした際に、復元する必要があるかどうかを考えます。
例えば、ビューを切り替えるタブなどは、選択した状態がブックマークとして保存できると便利でしょう。そのような状態は、URLのクエリパラメータとして表現します。
そうなると残りの状態は、ユーザの入力内容や一時的なコンポーネントの状態などです。これらはコンポーネント内部のステート、必要であれば独立したカスタムフックやコンテクストで局所的に管理できるでしょう。
もし、それが難しいとなった場合に初めて、状態管理ライブラリの導入を検討します。
状態管理ライブラリは、巨大なグローバル変数です。
無秩序な利用はメンテナンスコストの肥大につながるため、慎重な導入が重要です。
コンテクストのファイルの書き方
Reactでは1ファイル1コンポーネントが推奨されることが多いですが、コンテクストについてはまとめて定義しても良いと考えています。
模擬的には以下のようなスタイルでコンテクストを定義します。
export interafce CounterContextValue {
count: number;
increment: () => void;
decrement: () => void;
}
export const CounterContex = createContext<CounterContextValue>({
count: 0,
increment: () => {},
decrement: () => {},
});
export interface CounterContextProviderProps {
initialCount: number;
children?: ReactNode;
}
export const CounterContextProvider: FC<CounterContextProviderProps> = ({
initialCount,
children,
}) => {
const [count, setCount] = useState(initialCount);
const increment = useCallback<CounterContextValue['increment']>(() => {
setCount((c) => c++);
}, []);
const decrement = useCallback<CounterContextValue['increment']>(() => {
setCount((c) => c--);
}, []);
return (
<CounterContex.Provider
value={{
count,
increment,
decrement,
}}
>
{children}
</CounterContex.Provider>
)
};
export const useCounterContext: () => CounterContextValue = () => {
return useContext(CounterContex);
};
CounterContextProvider
を定義することで、プロバイダに与える値の処理をカプセル化することができます。同様の手法で、複数のコンテクストを利用する際にプロバイダをまとめることも可能になります。
useCounterContext
を定義することで、コンテクストを利用する条件が存在する場合に、その条件をカスタムフック内にカプセル化することができます。
コンテクストの定義だけではなく関連するコンポーネントやフックの定義を行うこと、それらの依存度が高いことが、1ファイル1コンポーネントの推奨から外れる理由となります。
hooks
ディレクトリ
- アプリケーション全体で利用するフックを配置します
api
ディレクトリ
- APIを利用するフックを配置します
前述したようにAPIのレスポンスのキャッシュ管理の責務も負うため、APIを利用するフックであることが明らかになるようにサブディレクトリに配置します。
- アプリケーションが行うAPI呼び出しは必ずAPIフックを介して行います
- コンポーネントが直接SWRやreact-queryを用いてはいけません
APIのレスポンスのキャッシュを一元的に管理し、状態の不整合が発生することを抑止するためです。
SWRを利用してどのようにAPI呼び出しとキャッシュの管理を行うのか、模擬的なコードで示します。
データを取得するAPIフック
export interface ReadTaskParameter {
id: string;
}
export interface UseReadTaskParams {
parameter: ReadTaskParameter;
}
export type UseReadTaskValue = SWRResponse<Task>;
export const getReadTaskKey: (params: {
parameter: ReadTaskParameter;
token: string;
}) => Key = ({ parameter, token }) => {
return {
path: '/tasks/{taskId}',
parameter,
token,
};
};
export const useReadTask: (
params: UseReadTaskParams
) => UseReadTaskValue = ({ parameter }) => {
const { token } = useAuth()
const key = token ? getReadTaskKey({ parameter, token }) : null;
const fetcher = async () => {
// Get data
};
return useSWR(key, fetcher);
};
export interface UseRevalidateReadTaskParams {
parameter: ReadTaskParameter;
}
export interface UseRevalidateReadTaskValue {
revalidateReadTask: () => Promise<void>
}
export const useRevalidateReadTask: (
params: UseRevalidateReadTaskParams
) => UseRevalidateReadTaskValue = ({ parameter }) => {
const { mutate } = useSWRConfig();
const { token } = useAuth();
const revalidateReadTask = useCallback(async () => {
if (!token) return;
const key = getReadTaskKey({ parameter, token });
await mutate(key);
}, [parameter, mutate, token]);
return { revalidateReadTask };
};
データを取得するAPIフックはどのような単位でキャッシュを管理するかも責務となるため、キャッシュを管理する単位となるkeyを取得する関数を公開します。
SWRはkeyにfunction型も受け付けますが、複数のアイテムをミューテートする際にkeyによるフィルタリングが難しくなるため、オブジェクトや文字列を渡すことを推奨します。このフィルタリングを容易にするために、keyの構造をアプリケーション全体で一貫的に定義し、 path
に該当する項目は固定値とすることをお勧めします。
認証が必要なアプリケーションでは、リクエストにトークンなど含めることを求められることが多いと思いますが、提示した例ではトークンの取得をフックの内部で行っています。UseReadTaskParamsにtokenを含め、フックを利用する側が明示的にトークンを渡すような実装ももちろん可能です。
ログインが必要なアプリケーションである場合、フックが呼び出される状況はすでに認証が完了していることが前提であることが多いため、利用する側がその詳細を意識する必要がないように、APIフックの内部に隠蔽することが多いです。
この時、keyにそのトークンなどを含めることを推奨します。あまりない状況ではあると思いますが、複数のユーザが端末を共有している状況で、別のユーザがログインした際に、前のユーザのキャッシュが表示されてしまうことを防ぐことができます。
状況によっては明示的にAPIの再検証を行う必要があると思いますが、そのような場合は専用のフックを定義します。
useSWR
の戻り値に含まれる mutate
を用いることもできますが、意図しないキャッシュの上書きを防ぐことができる、直接APIの値を用いないコンポーネントからの再検証の明示できるといった利点があります。
意図しないキャッシュの上書きをより厳密に防ぐには、 useSWR
の戻り値を全て返すのではなく、 mutate
を除く必要がありますが、その分フックの記述は煩雑になります。
データを更新するAPIフック
export interface PatchTaskParameter {
id: string;
}
export interface UsePatchTaskParams {
parameter: PatchTaskParameter
}
export interface UsePatcTackValue {
isMutating: boolean;
patchTask: (params: {
value: {
title?: string | null;
body?: string | null;
};
}) => Promise<{} | { error: Error }>;
}
export const usePatchTask: (
params: UsePatchTaskParams
) => UsePatchTaskValue = ({ parameter }) => {
const [isMutating, setIsMutating] = useState(false)
const { mutate } = useSWRConfig();
const { token } = useAuth();
const patchTask = useCallback(async ({ value }) => {
if (!token) return;
setIsMutating(true);
try {
// Update task and get result
const key = getReadTaskKey({ parameter, token });
await mutate(key, result, { revalidate: false });
return {};
} catch (error) {
return { error };
} finally {
setIsMutating(false);
}
}, [parameter, mutate, token]);
return { isMutating, patchTask };
};
データを取得するAPIフックが提供するkeyを取得する関数を使用することで、keyの具体的なフォーマットを知ることなく、正しいkeyを得ることができます。
mutate
を実行する段階ではすでにレスポンスを取得している状況なので、 { revalidate: false }
を渡して再検証を抑制します。
patchTask
が戻り値として Promis<{} | { error: Error }>
のように成功時に空オブジェクトを返すのは、利用する側でエラーの発生を "error" in result
のような型ガードで安全に処理することができるためです。
もちろん、更新された結果を返すようにしても問題はありません。リソースの作成では、作成された結果を直ちに利用する場面も多いでしょう。
仮にTaskのリストを取得するAPIフックがある場合は、次のように該当するリソースのみを更新することができます。
const key = getListTaskKey({ token })
await mutate(
key,
(current) => {
return current.map((item) => {
if (item.id !== parameter.taskId) {
return item;
}
return result;
});
},
{ revalidate: false }
);
リソースが関連するAPIの数が少なかったり単純である場合は、明示的なキャッシュの管理でリクエスト数を抑制することができますが、そうでない場合は考え方を変える必要があります。
例えば、リストに順序がある場合やページネーションを伴うリストに関連する場合などは、整合性を維持することが難しくなってくるため、APIから新たな値を取得した方が単純になるでしょう。
また、データを取得するAPIフックと更新するAPIをはっきりと分割しているため、楽観的更新はできません。
そのような振る舞いが必要となる場合は、ユースケースを明示した特別なフックとして定義するか、コンポーネントのローカルに配置して、意図しない依存が発生しないように注意するようにしましょう。
その他のフックの配置
フックの数が少ないうちは hooks
ディレクトリの直下に配置しても問題ないでしょう。
以前はUIに関連するフックを配置する helpers
ディレクトリ、 UIに関連しないフックを配置する utils
ディレクトリを用意することもありましたが、限定的なコンポーネントで利用するフックはそのコンポーネントの近くに配置するようにすると、 hooks
ディレクトリに配置されるフックはそれほど多くありませんでした。
components
ディレクトリ
- UIを持たないなど、
templates
、organisms
、molecules
、atoms
に該当しないコンポーネントを配置します
layouts
ディレクトリ
Layoutコンポーネントを定義するディレクトリです。
Layoutコンポーネントは、propsとして受け取ったReactNodeをどのように配置するかという責務ののみを負うコンポーネントです。
export interface LayoutDefaultProps {
header: ReactNode;
main: ReactNode;
}
この例では、headerとmainに相当するReactNodeを要求しますが、それらがどのような実装であるかについては関心を持ちません。
この方式を取ることで、複数のページにおいて一貫したレイアウトを維持しつつ、特定のページではヘッダーを変更するといった要求に対して、そのロジックをLayoutコンポーネントから切り出すことができます。
- Templateコンポーネントで利用するレイアウトは全てLayoutコンポーネントとして定義する必要はありません
局所的なページで利用されることが自明なレイアウトは、そのTemplateコンポーネントの近くに配置することで、意図しない依存を避けることができます。
1つのページでしか利用しないレイアウトであれば、コンポーネントとして定義せず、Templateコンポーネントに直接記述しても良いでしょう。
layout.tsx
による状態の共有
ユーザの操作を保持するヘッダーコンポーネントなど、そのコンポーネントがアンマウントされた時点でその状態は失われます。
これはApp Routerの layout.tsx
を用いた場合も同様です。
異なるコンポーネントに切り替わる画面遷移で状態を保持するためには、より上位の layout.tsx
でコンテクストを提供する必要があるでしょう。
Pages Routerでも、Per-Page Layoutsのテクニックを利用することで、 _app.tsx
で全てのコンテクストを提供するのではなく、限定された範囲でコンテクストを提供することができます。
templates
ディレクトリ
Templateコンポーネントを配置するディレクトリです。
Templateコンポーネントは概ねルーティングに対応し、 pages.tsx
から与えられたpropsに基づいてページを表示するコンポーネントです。
- 適用するLayoutコンポーネントを指定します
定義されたLayoutコンポーネントから適切なものを選択し、要求されるReactNodeを提供します。
- 画面の描画に必要なデータをAPIフックから取得します
- APIからデータを取得中のローディング表示などの制御を行います
export const TaskDetailTemplate: FC<TaskDetailTemplateProps> = ({
taskId,
}) => {
const {
data: task,
error: taskError,
} = useReadTask({ parameters: { taskId }});
return (
<LayoutDefault
header={<GlobalHeader />}
main={(() => {
if (!task || !taskError) {
return <Loading />;
}
if (taskError) {
return <ErrorTemplate error={taskError} />;
}
return <TaskMain task={task} />;
})()}
/>
);
};
ログインが前提となるページは、もちろんアプリケーション全体として適切な設定が行われていることが前提ですが、基本的にソフト404で問題ないと考えています。
逆にクローラのアクセスを受け付けインデックスされることが期待されるページについては、 page.tsx
で適切な処理を行う必要があります。
- Templateコンポーネントのpropsはルーティングの詳細に関心を持つべきではありません
Templateコンポーネントはアプリケーションが想定する値をpropsとして表現します。
例えばリストのソート順はリテラル型で表現されることが多いと思いますが、クエリパラメータで与えられた値のバリデーション、リテラル型への変換などはpage.tsx
の責務です。
クエリパラメータは慣習的な命名が行われることがありますが、アプリケーション内部では別の表現を用いている場合それを持ち込むべきではありません。
ディレクトリの構造やコンポーネントの命名
基本的にルーティングと対応して別の箇所で利用される可能性がほとんどないことから、アプリケーション全体で一意の命名をする必要はないと考えます。
例えばタスクのCRUDを提供する場合、次のような構造と命名にすることが多いです。
src
└─ templates
└─ tasks
├─ IndexTemplate (/tasksに対応)
├─ CreateTemplate (/tasks/createに対応)
├─ DetailTemplate (/tasks/[taskId]に対応)
└─ EditTemplate (/tasks/[taskId]/editに対応)
タスクが所有するリソースに対するページを提供するのであれば、 tasks
ディレクトリにリソース名のディレクトリを作成し、そこに対応するTemplateコンポーネントを配置します。
organisms
ディレクトリ
Organismコンポーネントを配置するディレクトリです。
Organismコンポーネントは、内部でコンテクストやAPIフックを利用しており、グローバルの状態を参照または更新するコンポーネントです。
- デザインの複雑さはコンポーネントの分類に際して考慮しません
アプリケーション開発における再利用性に焦点を当てているためです。
-
organisms
ディレクトリに配置されるコンポーネントは可能な限り自己完結的であるべきです
このディレクトリに配置されるコンポーネントは、グローバルの状態を参照または更新するコンポーネントでかつ、複数のページから参照されるものであるはずです。
そのため、エラーが発生した場合にも通常のユースケースに復帰するための振る舞いや、エラーの発生を利用するコンポーネントに対して通知するコールバックを提供すべきです。
molecules
ディレクトリ
Moleculeコンポーネントを配置するディレクトリです。
Moleculeコンポーネントは、グローバルの状態を参照または更新しない、アプリケーション固有の知識を持つコンポーネントです。
-
interfaces
ディレクトリで定義された型に依存するコンポーネントはMoleculesコンポーネントです - propsとして単純な値のみを受け取る場合でも、アプリケーション内の特定の文脈で利用されることを想定したコンポーネントも該当します
アプリケーションのあらゆる箇所で利用できるわけではないが、特定の機能の範囲で再利用可能であることを示します。
グローバルの状態を参照または更新しないコンポーネントであるため、安全に利用することが可能です。OrganismコンポーネントとMoleculeコンポーネントを分ける目的は、安全に利用できるコンポーネントであるかどうかを明確にするためです。
atoms
ディレクトリ
Atomコンポーネントを配置するディレクトリです。
Atomコンポーネントはアプリケーションの特定の文脈に依存せず、あらゆる箇所で利用可能なコンポーネントです。
- 比較的複雑なコンポーネント、例えばアイコンやボタンを持つアイテムを持つリストのようなコンポーネントであっても、利用される文脈が限定されないのであればそれはAtomコンポーネントです
スタイルガイドで定義されたデザインを実装したコンポーネントなどが該当するでしょう。
MUIなどのコンポーネントライブラリを利用する場合などは、該当するコンポーネントがほとんど存在しない状況も想定されます。
-
input
やbutton
など組み込み要素に対応するコンポーネントは、forwardRef
を用いて透過的に扱えるようにすることを推奨します
React Hook Formなど直接DOMにアクセスするサードパーティライブラリが存在するためです
コンポーネントの分類や配置に対する考え方
ディレクトリ構成でコンポーネントを細かく分類する考え方を紹介しましたが、コンポーネントを定義する際に最初からどの種類に分類されるかという方法は推奨しません。
特に開発の初期段階では、そのコンポーネントを再利用する場面が存在するか、また再利用可能な振る舞いであるかを判断する材料が不足しているためです。
コンポーネントの実装において重視する点は、上位の階層の知識に依存しないこと、可能な限り単純であることです。
上位の階層の知識への依存とは、コンポーネントがどこで利用されるかによって、自身の振る舞いを変えるような状況です。コンポーネントの総数は少なくなるかもしれませんが、内部に複数の関心が入り込み条件分岐などが複雑になることによって、保守性の低下を招きます。
利用される文脈によって挙動が変わるのであれば、文脈ごとのコンポーネントを定義する、必要であればプレゼンテーションのみを責務とするコンポーネントとして抽出し、固有の挙動を利用する側が注入するという考え方を推奨します。
アドホックなコンポーネントやフックなどの定義
前述の考え方を実現するために、限定的な文脈で利用されるコンポーネントやフックを、コンポーネントの内部や機能ごとのディレクトリで定義する考え方を紹介します。
タスクのCRUDを例に、どのように考えるかを順に示していきます。
基本的な型やAPIフック、Layoutコンポーネントはすでに実装されているものとします。
最初はTemplateコンポーネントに直接マークアップを記述していきます。対応するAtomコンポーネントがすでに実装されているのであれば、それを利用します。
src
└─ templates
└─ tasks
├─ IndexTemplate
│ └─ components
│ └─ TaskList
├─ CreateTemplate
│ └─ components
│ └─ CreateForm
├─ DetailTemplate
└─ EditTemplate
└─ components
└─ EditForm
TaskListはtaskの配列を受け取りリストとして表示するコンポーネントです。繰り返しに必要なkeyを隠蔽するため、最初から個別のコンポーネントとして切り出すことが多いです。
CreateTemplateとEditTemplateで用いるフォームの入力項目の構成は同じであることが明らかになりました。
src
└─ templates
└─ tasks
├─ components
│ └─ TaskFormBody
├─ IndexTemplate
│ └─ components
│ └─TaskList
├─ CreateTemplate
├─ DetailTemplate
└─ EditTemplate
タスクに関連する機能の文脈内で用いるコンポーネントを配置する components
ディレクトリを tasks
ディレクトリの直下に作成し、TaskFormBodyコンポーネントを定義します。
export interface TaskFormBodyProps {
value?: Task;
onChange: (value: Omit<Task, 'id'>) => void;
};
export const TaskFormBody: FC<TaskFormBodyProps> = ({
value,
onChange,
}) => {
const onTitleInputChange = useCallback<
ChangeEventHandler<HTMLInputElement>
>(
(event) => {
onChange(event.target.value);
},
[onChange]
);
// Define other input callbacks
return (
<form id="taskForm">
{/* Form input components */}
</form>
);
};
TaskFormBodyはフォームの内容を送信するボタンは持ちません。作成と変更でテキストや振る舞いなどが異なる可能性が大きく、その制御はTaskFormBodyを利用するコンポーネントが負うべき責務であるためです。
form要素にidを指定することで、コンポーネント外部の任意の場所のサブミットボタンと紐づけることができます。また、form要素を用いることで、キーボードを用いたサブミットなどブラウザのデフォルトの挙動を阻害することがありません。
次に、タスク機能では固有のヘッダーを表示したいという要求が発生しました。ただし、作成・編集画面では入力中に別の機能になるべく遷移しないよう、簡略化されたヘッダーを表示します。
src
└─ templates
└─ tasks
├─ components
│ ├─ TaskGlobalHeader
│ └─ TaskFormBody
├─ IndexTemplate
│ └─ components
│ └─TaskList
├─ CreateTemplate
├─ DetailTemplate
└─ EditTemplate
タスク機能固有のヘッダーであるTaskGlobalHeaderをタスク機能に関連する共通のコンポーネントとして定義し、IndexTemplateとDetailTemplateはそれを利用します。
CreateTemplateとEditTemplateのヘッダーは、はボタンのラベルやユーザの入力がない場合の振る舞いが異なるため共通化しません。もちろん、コードの見通しを良くするために、TaskListのように切り出しても問題ないでしょう。
タスク機能固有のヘッダーは状態を持ち、タスク機能を利用している間はその状態を保持する必要が出てきました。
useState
などを用いた状態の管理では、タスクの作成、変更ページに遷移するとその情報は失われてしまいます。
src
└─ templates
└─ tasks
├─ contexts
│ └─ TaskGlobalHeaderContext
├─ components
│ ├─ TaskGlobalHeader
│ └─ TaskFormBody
├─ IndexTemplate
│ └─ components
│ └─TaskList
├─ CreateTemplate
├─ DetailTemplate
└─ EditTemplate
TaskGlobalHeaderの状態を保持する、TaskGlobalHeaderContextを定義し、 src/app/tasks/layout.tsx
で提供します。
/tasks
以下のルーティング内ではコンテクストが維持され、複数ページにまたがって状態を共有することができます。
このように、新たな機能に関連するページを実装する際は、利用するコンポーネントやフック、コンテクストをアドホックに定義し、必要に応じて共通化するという手順を踏みます。
アドホックなコンポーネントなどには決して外から依存してはいけません。必要性が生じた段階で、上位の階層のディレクトリや organisms
、 molecules
ディレクトリに適切な命名で再配置します。
このように実装を進めることで、必要性が明確でない時点での過剰な共通化や、複数の異なる文脈から依存されることによる複雑性の増大を回避することができます。
なお、App Routerでは page.tsx
を定義しない限りルーティングが公開されることはないため、 app
ディレクトリ内に同様のディレクトリ構造で実装を行うことも可能です。プライベートフォルダを利用することで、安全も確保できるでしょう。
ただ、やはり app
ディレクトリはルーティングに関する知識のみを保持し、その内部で定義されたコンポーネントはServer Componentであるという状態を維持することは、特にアプリケーションの規模が大きくなってきた際に、構造を把握しやすいというメリットがあると考えています。
Discussion