フロントエンドのディレクトリ構成

どうも、uzimaru です。

最近やってるディレクトリ構成について軽くまとめます。

構成

src/
    ├─ model/
    ├─ components/
    ├─ lib/
    ├─ api/
    ├─ usecase/
    └─ pages/

一つずつ見ていきましょう

model/

ここで言う model はアプリケーション(サーバーサイドとクライアントをあわせたくくり)の model ではなくフロントエンドで利用される型を定義する場所です。

フロントエンドの view は基本的にここの型に依存するように実装します。(model と言いつつ view model みたいな感じ)

また、ここの型は api に依存する必要はないです。

例えば、api からは unix timestamp で時間が返ってくるかもしれませんがフロントエンドでは dayjs を使いたいという方針なら以下のように定義します。

tsx// api から返ってくるデータ
interface APITodo {
    id: number
    title: string
    is_done: boolean
    created_at: number
}

// model で定義する型
export interface Todo {
    id: string // フィールドの型を変えても良い
    title: string
    isDone: boolean // フィールドの名前を変えても良い
    createdAt: Dayjs // フィールドの型を変えても良い
}

components/

汎用的に使われるコンポーネントを置いておく場所です。

ここで言う汎用的とはページを跨いでも使われるものです。例えば、User のアイコンとかボタンとか。

なので、ここのコンポーネントにはいわゆる副作用(window を触ったり、api call をしたり)は存在しません。もっと言うと状態もない方が好ましいです。

lib/

ドメインに関係なく利用される関数を置きます。すべて。

ここでは、数値の計算をする関数も hooks も同レベルで扱います。hooks/ というディレクトリは作りません。

考えてみれば custom hooks と言うのは react というライブラリを使っている関数なのでそこだけ特別視するのもちょっと微妙な気がしています(react のコンポーネントの中でしか使えないという制約はありますが…)

例えば、local storage を触る関数を作るとします。local storage を触るやつとそれを hooks にしたものを作ります。

hooks ディレクトリを作った場合こんな感じになるはず

lib/
    └─ localStorage.ts
hooks/
    └─ useLocalStorage.ts

ここの分離に本質的な意味はあるでしょうか?

named exports の強制を採用しているプロジェクトの場合以下のような構成でいいような気がしています。

tsxexport const getFromLocalStorage = (key: string): T | null => {
    // impl
}

export const storeFromLocalStorage = (key: string, value: T | null) => {
    // impl
}

export const useLocalStorage = (key: string): [T, (key: string, value: T | null) => void] => {
    // impl
}

api/

api call をするための関数を置いておく場所です。

ここでは、原則として zod 等のライブラリで api からのデータをバリデーションするようにします。このディレクトリの関数から得られるデータがその型であることを保証するためです。

もし、データの型が誤っていた場合は他の層でエラーにならずに api call の段階でランタイムエラーになることになります。

usecase/

一般的に usecase といえばビジネスロジックを記述する部分ですが、フロントエンドなのでそんな大層なものはありません(諸説)

ここで言う usecase は、api call と model へのマッピングとリソースを mutate する手段の提供です。

tsxexport const useTodoList = () => {
    const {data, mutate, error} = useSWR('todo list', async () => {
        const data = await fetchTodoList()
        
        return data.map(x => ({
            id: x.id.toString(),
            title: x.title,
            isDone: x.is_done,
            createdAt: dayjs.unix(x.created_at)
        }))
    })

    const doneAction = useCallback(async (id: string) => {
        await doneTodo(id)
        mutate(prev => {
            return prev.map(x => {
                return x.id === id
                    ? {...x, isDone: true}
                    : x
            })
        })
    })

    return {
        data,
        mutate: {
            done: doneAction
        },
        error
    }
}

今回は、TodoList のデータを fetch してきて Todo[] 型に変換するのと、任意の Todo を done にするための usecase を例にしました。

最初に

  • data の fetch と model へのマッピング
  • リソースの mutate の手段の提供

と書きましたが、これはアプリケーションの状態を取得・更新するための手段の提供と言い換えれます。

フロントエンドの究極的な役割はユーザーの入力からアプリケーションの状態を変えるための橋渡しです。その点で言うと、ここでやってることはフロントエンド的には usecase っぽいです(あと、基本的に hooks になるので usecase で hooks っぽい命名になってるのが好きです)

pages/

ここは、Next.js とかと同じでページの定義が入ってる場所です。

データのフローとしては

  1. usecase からデータとその更新方法を取ってくる
  2. usecase の型を使ってコンポーネントを実装
  3. 必要な箇所でデータの更新をする

という感じ。

もし、page のテストをしたくなったら usecase の mock をすることでテストもすることができます。

また、page でのみ利用するコンポーネントや hooks は、page のディレクトリに定義します。

tsxpages/
    └─ todos
            ├─ index.tsx
            ├─ components/
            └─ hooks/

さっきは lib だったのにここでは hooks って使ってるじゃん!言ってること違うじゃん!ってなるかもしれないですが、ここではマジで hooks しか定義しない(普通の関数を export しない)ので大丈夫です。

まとめ

ここまで書いてきましたが、やってるプロジェクトはそこまで大きいものじゃないというのとプロジェクトによってはフロントエンド特有の状態があり得るのでこの構成で耐えないかもしれません。

しかし、ある程度の秩序がありシンプルな構成を求めたらこのような構成になりました。

また、usecase は SWR 以外のライブラリにしたときにも有効だと思ってます。

例えば、SWR から別のライブラリに移行することを考えたとき、usecase のインターフェースに準拠していたら view の変更は一切ないことになります。usecase の正しさを計るのもデータが正しいかを見ればいいということになります(view は usecase のデータから構築されることが前提なので同じデータが来れば同じ UI になっているはず)

もう少し、運用をしてみてなにかありそうだったらブログを更新します。では。