📁

ロジックを追いながらNext.jsのディレクトリ構成について理解を深めてみる

2024/10/24に公開

https://www.amazon.co.jp/TypeScriptとReact-Next-jsでつくる実践Webアプリケーション開発-手島-拓也/dp/4297129167/ref=sr_1_1?dib=eyJ2IjoiMSJ9.Wb1w0ddvLhlqjXBArC2pSppf_oZKRbbc7_QZ2-FqmCeSvguYsMWcuTHPOakTPi_szgxYS_368H-MS3mcOYT-d46PCDLEFtDTsDBcOEyr9xABiQPEEHYroeLx4u5zmfEjDs4eFR5tcaFuR_ASdChR8nhCdl46QHcPcX_mzZo8KBck2c2RCqw-zNeviF0BgVxEN-TZvyZQtWL3btJ_DP3JUz9gY371uSz1S8fvwyki-bIz1HW2Pubt6JmZBiG0s4PG7kx3CHSuX2SpoLft6uVYiVSk0a9WcAi6O-Y0fFaVwYQ.54Kv5-7f0OS3QI7-E322v8uheq5pwSDZ0cp2GmRKacM&dib_tag=se&keywords=react+typescript&qid=1729745390&sr=8-1
『TypescriptとReact/Next.jsでつくる実践Webアプリケーション』という書籍でフロントエンド周りの学習をしています。
現在は書籍に沿ってサンプルアプリケーションの開発を進捗しています。
このアプリケーション開発では、コンポーネント開発の手法としてAtomic Designというコンポーネントを最小単位から段階的に構築していく考え方が取り入れられています。
開発の序盤はatomsやmoleculesといった細かい粒度のコンポーネントの実装を行い、成果物をStorybookで都度確認するという流れが続き、比較的順調に進められましたが、より大きなコンポーネント(OrganismsやPages)を作る段階になってくると、ロジックが複雑化し、処理の流れが追いきれなくなってきました。

処理の流れが追いきれなくなってきた原因の一つに、その役割がいまいちわからないディレクトリおよびファイルが増えてきたことがあると感じました。
components以外のディレクトリ、特にutilsservicesといったディレクトリのファイルではアプリケーションの各所で再利用される関数が宣言されており、それらがどこでどのように呼び出されているのかがわかりにくいという状態になってしまいました。
そのため、ここではutilsservices配下で定義されているロジックの処理を追いかけながらコードの見通しを立てていきたいと思います。

なお、今回確認していくサンプルアプリケーションのソースコードは以下で見ることができます。
https://github.com/gihyo-book/ts-nextbook-app

まずは、ディレクトリ構成を確認します。
src配下にはcomponents, containers, contexts, pages, services, themes, types, utilsというディレクトリが配置されています。
先述の通り、この中で自分が混乱する原因となっているutilsservicesのそれぞれのディレクトリの役割について整理するところから始めたいと思います。

utilsservicesの違い

どちらのディレクトリにも関数が定義されたファイルが格納されており、別のファイルからそれらの関数をインポートして利用するという構成になっています。
これらのディレクトリを使い分ける上での線引きには調べてみたところ諸説あるよう(libというディレクトリを用意することもある?)だったので、本サンプルアプリでの使われ方から推察し、以下のような考え方で使い分けられていると解釈しました、

  • utils:
    比較的粒度が小さい汎用的な関数を定義したファイルが配置されている。ここで管理される関数はコンポーネント自身やアプリケーション外部とのやり取りからは独立したもの。
  • services:
    外部との通信を行う関数やビジネスロジックに即した関数(データの処理やユーザー入力の処理など)を定義したファイルが配置されている。API コールや、データベースの読み書き、認証ロジックなど、アプリケーション外部とのやり取りなど、アプリケーションの特定の機能に密接に関連する関数。

以上はあくまでも自分の解釈であります。
極端な話としては要件に応じて使い分ければ良いということになりそうですね…。

ロジックを追いかけてみる

utils配下のindex.tsxを見てみると、fethcerという関数のみが定義されています。
このfetcher関数がこのアプリケーション内でどのように利用されているのかを追いかけていけばutilsディレクトリへの理解がさらに深まるのではないかと考えました。

utils/index.tsxを起点として

まずはutils/index.tsxで定義されているfetcher関数を見てみます。
今回はindex.tsxに直接関数が定義されています。定義する関数が少ない場合はこのようにindex.tsxに直接関数を書くことでも問題なさそうですが、関数自体が複雑化する場合は別ファイルとして切り出して定義し、index.tsx自体は複数の関数やモジュールをエクスポートするためのエントリーポイントとして使用するという構成の方が良さそうです。

export const fetcher = async (
  resource: RequestInfo,
  init?: RequestInit,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any> => {
  const res = await fetch(resource, init)

  if (!res.ok) {
    const errorRes = await res.json()
    const error = new Error(
      errorRes.message ?? 'APIリクエスト中にエラーが発生しました',
    )
    throw error
  }

  return res.json()
}

ここではJavascriptのデフォルトの関数であるfetch関数をラップするfetcher関数が定義されています。
fetch 関数は、HTTPリクエストによりサーバーと非同期通信を行うための関数で、レスポンス結果によって.then.catchで実装された後続の処理を行います。レスポンスが成功であれば.then, 失敗であれば.catchと後続の処理に流れるのですが、デフォルトのfetch関数の場合、失敗扱いとなるのはネットワークレベルのエラー(接続の失敗、DNS解決の失敗など)に限られるという特徴があり、サーバーからのレスポンスが 404 や 500 のようなHTTPエラーであっても、fetch関数としては成功扱いになってしまうそうです。

そのため、fetcher関数ではレスポンスが200台以外の場合にはエラーを投げるという処理を実装し、アプリケーションの中でHTTPリクエスト&レスポンスに基づく処理を扱いやすくしています。

fetcher関数の構成としては以下のとおりです。

  • 第一引数にはリクエスト先のURLを示すresource、第二引数にはリクエストの際のHTTPメソッドやリクエストヘッダー、リクエストボディなどのオプション設定を示すinitをとります。それぞれそのままfetch関数の引数として渡されます。
  • リクエストオプションはなくともHTTPリクエストは送信できる(その場合は、GETリクエスト、リクエストヘッダーは基本的な内容、リクエストボディはなし、クッキーや認証情報は送らないというデフォルトの動作となります)ので、引数initには?をつけています。
  • それぞれの引数の型にはRequestInfoRequestInitというものが付与されていますが、これらはTypeScriptの標準ライブラリに組み込まれたFetch APIのリクエストを型安全に扱うための型定義となります。
  • RequestInfostring または Request オブジェクトのいずれかを受け取ります。
  • RequestInitはリクエスト時のオプションを表すオブジェクトとなります。

繰り返しますが、fetcher関数の役割としてはレスポンスが200以外の場合はエラーを投げるのみです。
では次に、このfetcher関数がどのように利用されているのかを追いかけてみます。

fetcher関数を追う

このサンプルアプリケーションにおいて、fetcher関数が利用されているのはすべてservices配下のファイルでした。

services配下にはauth, products, usersというフォルダが用意されており、その配下に、signinsignoutなどの認証系の処理、get-all-productsget-all-users といったサーバからデータを読み取る処理がそれぞれのファイルで定義されています。
今回はログイン認証を行うservices/auth/signin.tsに注目してfetcher関数がどのように利用されているかを確認してみます。

import { ApiContext, User } from '@/types/data'
import { fetcher } from '@/utils'

export type SigninParams = {
  username: string
  password: string
}

const signin = async (
  context: ApiContext,
  params: SigninParams,
): Promise<User> => {
  return await fetcher(
    `${context.apiRootUrl.replace(/\/$/g, '')}/auth/signin`,
    {
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(params),
    },
  )
}

export default signin

ここで定義されているsignin関数は引数にcontextとユーザー入力値のパラメータとしてstring型の値であるusernamepasswordを受け取ります。

context.apiRootUrlにはこのプロジェクトで利用しているユーザー認証サーバのURLが相当します。今回はローカルで立ち上げた別プロジェクトをユーザー認証サーバとして利用するので、http://localhost:port番号/auth/signinというエンドポイントにPOSTリクエストを送信し、リクエストボディにparamsとして受け取ったusername, passwordの値を乗せて送信するということになります(実際のアプリケーションとしてはセキュリティ面で考慮すべき点がありそうですが、今回はサンプルアプリのため割愛します)。このURLの部分やエンドポイントは利用する認証サーバによって変わります。

今回のローカルサーバでは、認証に成功した場合は201ステータスとともにユーザー情報(authUser)と token を返し、認証が失敗した場合には、401 エラーレスポンスが返されるという仕様になっています。

改めてsignin関数を見てみると、処理としてはfetcher関数を実行し、その戻り値を返すに留まっています。つまり、認証が失敗し、fethcer関数がエラーを投げた場合の例外処理はこの段階ではまだ実装されていません。

では次にsignin関数を追いかけてみます。

signin関数を追う

signin関数はcontext/AuthContext/index.tsxで呼び出されています。

import type { ApiContext, User } from "@/types/data"
import React, { useContext } from "react"
import useSWR from "swr"
import signin from '@/services/auth/signin'
import signout from '@/services/auth/signout'

type AuthContextType = {
  authUser?: User
  isLoading: boolean
  signin: (username: string, password: string) => Promise<void>
  signout: () => Promise<void>
  mutate: (
    data?: User | Promise<User>,
    shouldRevalidate?: boolean,
  ) => Promise<User | undefined>
}

type AuthContextProviderProps = {
  context: ApiContext
  authUser?: User
}

const AuthContext = React.createContext<AuthContextType>({
  authUser: undefined,
  isLoading: false,
  signin: async () => Promise.resolve(),
  signout: async () => Promise.resolve(),
  mutate: async () => Promise.resolve(undefined),
})

export const useAuthContext = (): AuthContextType => useContext<AuthContextType>(AuthContext)

export const AuthContextProvider = ({
  context,
  authUser,
  children,
}: React.PropsWithChildren<AuthContextProviderProps>) => {
  const { data, error, mutate } = useSWR<User>(
    `${context.apiRootUrl.replace(/\/$/g, '')}/users/me`,
  )
  const isLoading = !data && !error

  const signinInternal = async (username: string, password: string) => {
    await signin(context, { username, password })
    await mutate()
  }

  const signoutInternal = async () => {
    await signout(context)
    await mutate()
  }

  return (
    <AuthContext.Provider
      value={{
        authUser: data ?? authUser,
        isLoading,
        signin: signinInternal,
        signout: signoutInternal,
        mutate,
      }}
    >
      {children}
    </AuthContext.Provider>
  )
}

ここには記載しませんが、_app.tsxではAuthContextProviderが子コンポーネントをネストする形で呼び出されています。このAuthContextProviderにネストされる子コンポーネントからはuseAuthContext()を利用することでauthUser, isLoadingsigninsignout, mutateが呼び出せるようになっています。

signin関数の第一引数のcontextはこのAuthContextProviderの定義の中であらかじめ設定されているので、AuthContextProvider以下の子コンポーネントでsignin関数を呼び出す場合はusernamepasswordの2つだけを渡せば良いようになっています。

では次にuseAuthContext()からsignin関数を取り出し、利用しているコンポーネントを確認してみます。

useAuthContext()を追う、containersディレクトリとの遭遇

src/containerというディレクトリに、SigninFormContainer.tsxというファイルがあります。
このファイルを見てみると…、

import SigninForm from "@/components/organisms/SigninForm"
import { useAuthContext } from "@/contexts/AuthContext"
import { useGlobalSpinnerActionsContext } from "@/contexts/GlobalSpinnerContext"

interface SigninFormContainerProps {
  onSignin: (error?: Error) => void
}

const SigninFormContainer = ({
  onSignin,
}: SigninFormContainerProps) => {
  const { signin } = useAuthContext()
  const setGlobalSpinner = useGlobalSpinnerActionsContext()

  const handleSignin = async (username: string, password: string) => {
    try {
      setGlobalSpinner(true)
      await signin(username, password)
      onSignin && onSignin()
    } catch (err: unknown) {
      if (err instanceof Error) {
        window.alert(err.message)
        onSignin && onSignin(err)
      }
    } finally {
      setGlobalSpinner(false)
    }
  }

  return <SigninForm onSignin={handleSignin} />
}

export default SigninFormContainer

ありました。 const { signin } = useAuthContext()signin関数を取り出して、handleSingin関数の中でそのsignin関数を呼び出していますね。
signin関数の成功・失敗に応じて、そのままonSignin関数を実行するか、catch内部の処理を行うかが制御されています。
ここで初めて、レスポンスが失敗した場合にエラーを投げるのみで終わっていたfetcher関数の例外処理が実装されています。
このonSignin関数は外部からPropsとして渡される関数なので、後ほど詳細を確認してみます。

次に進む前に、このファイルが配置されているcontainerディレクトリについても気になったので調べてみました。
これまでatomic designに基づき、コンポーネントの粒度に応じて、atomsやmolecules…,とコンポーネントファイルを分類するという手法にふれてきました。
しかし意識しないうちに、「見た目」を表現すべきコンポーネントに「振る舞い(ロジック)」を実装してしまい、コードの管理が煩雑になってしまうということがよくありました。

こうした問題を解決するためにcontainerディレクトリを用意し、ここに「振る舞い」を定義したコンポーネントをまとめて管理することで、「見た目」を実装するコンポーネントと「振る舞い」を定義するコンポーネントの責務をはっきりと分けるという考え方があるようです。
containerコンポーネントはロジックの管理に集中し、見た目に関する処理はpresentational(表示専用)コンポーネントに委譲することで、コードの再利用性と可読性が向上します。

こちらの記事が参考になりました。
Next.js アプリのディレクトリ構成を考える(Atomic Design と Presentational and Container Components)

今回containersディレクトリに配置されたSigninFormContainer.tsxではsignin関数を実行し、その結果による「振る舞い」を定義しています。
そして、その「振る舞い」はhandleSigninによってSigninFormにコンポーネントに伝搬されています。

import React from 'react'
import Button from "@/components/atoms/Button"
import Input from "@/components/atoms/Input"
import Text from "@/components/atoms/Text"
import Box from "@/components/layout/Box"
import { useForm } from "react-hook-form"

export type SigninFormData = {
  username: string
  password: string
}

interface SigninFormProps {
  onSignin?: (username: string, password: string) => void
}

const SigninForm = ({ onSignin }: SigninFormProps) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SigninFormData>()
  const onSubmit = (data: SigninFormData) => {
    const { username, password} = data

    onSignin && onSignin(username, password)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Box marginBottom={1}>
        <Input
          {...register('username', { required: true })}
          name="username"
          type="text"
          placeholder="ユーザ名"
          hasError={!!errors.username}
        />
        {errors.username && (
          <Text color="danger" variant="small" paddingLeft={1}>
            ユーザ名は必須です
          </Text>
        )}
      </Box>
      <Box marginBottom={2}>
        <Input
          {...register('password', { required: true })}
          name="password"
          type="password"
          placeholder="パスワード"
          hasError={!!errors.password}
        />
        {errors.password && (
          <Text color="danger" variant="small" paddingLeft={1}>
            パスワードは必須です
          </Text>
        )}
      </Box>
      <Button width="100%" type="submit">
        サインイン
      </Button>
    </form>
  )
}

export default SigninForm

SigninFormコンポーネントはuseFormを使って、usernamepasswordそれぞれに入力必須のバリデーション設定した入力フォーム(「見た目」)を構成しています。入力値のバリデーションチェックまではSigninForm側で行いますが、適切な入力値を受け取った場合はonSubmitを契機として外部からPropsとして渡されているonSignin関数を実行します。
onSignin関数として実行されるのは、「振る舞い」として実装されたSigninFormContainerコンポーネントから渡されているhandleSignin関数です。

このように、入力フォームという「見た目」を定義するSigninFormコンポーネントとsignin関数/fetcher関数による「振る舞い」を定義するSigninFormContainerコンポーネントという形ではっきりと責務が分離することで、コードも管理がしやすくなるということですね。

containerコンポーネントを追う

次にSigninFormContainerコンポーネントの呼び出し元を確認してみます。
SigninFormContainerコンポーネントはpages/signin.tsxで呼び出されています。

import { NextPage } from "next"
import { useRouter } from "next/router"
import Layout from "@/components/templates/Layout"
import Flex from "@/components/layout/Flex"
import Box from "@/components/layout/Box"
import AppLogo from "@/components/atoms/AppLogo"
import SigninFormContainer from "@/containers/SigninFormContainer"

const SigninPage: NextPage = () => {
  const router = useRouter()

  const handleSignin = async (err?: Error) => {
    if (!err) {
      const redirectTo = (router.query['redirect_to'] as string) ?? '/'

      console.log('Redirecting', redirectTo)
      await router.push(redirectTo)
    }
  }

  return (
    <Layout>
      <Flex
        paddingTop={2}
        paddingBottom={2}
        paddingLeft={{ base: 2, md: 0 }}
        paddingRight={{ base: 2, md: 0 }}
        justifyContent="center"
      >
        <Flex
          width="400px"
          flexDirection="column"
          justifyContent="center"
          alignItems="center"
        >
          <Box marginBottom={2}>
            <AppLogo />
          </Box>
          <Box width="100%">
            <SigninFormContainer onSignin={handleSignin} />
          </Box>
        </Flex>
      </Flex>
    </Layout>
  )
}

export default SigninPage

SigninFromContainerに渡される関数はhandleSigninとして定義されています。

このhandleSignin関数としては引数に何も渡されなければif文で定義したuseRouterを用いた画面遷移の処理の実行、引数(エラーオブジェクト)が渡された場合は何もしない、つまり画面遷移しない、サインインの画面に留まるという処理になります。
SigninFormContainerの定義を改めて見てみます。

const handleSignin = async (username: string, password: string) => {
    try {
      setGlobalSpinner(true)
      await signin(username, password)
      onSignin && onSignin()
    } catch (err: unknown) {
      if (err instanceof Error) {
        window.alert(err.message)
        onSignin && onSignin(err)
      }
    } finally {
      setGlobalSpinner(false)
    }
  }

リクエストに失敗した場合のcatch処理ではonSignin(err)と関数に引数が渡されるようになっており、認証に成功した場合、失敗した場合のそれぞれの処理がここで実装されていることがわかりました。
この部分においても「見た目」(画面遷移は「見た目」として考えるのが適切なのかという疑問はさておき…)と「振る舞い」の分離がなされていると感じました。

pagesに辿り着く(ゴール)

さて、ここまでロジックを追いかけてきましたが、pagesコンポーネントに辿り着いたので、一旦ゴールとします。
最後に認証リクエストが成功した場合の動きを確認しておきます。

  const handleSignin = async (err?: Error) => {
    if (!err) {
      const redirectTo = (router.query['redirect_to'] as string) ?? '/'

      console.log('Redirecting', redirectTo)
      await router.push(redirectTo)
    }
  }

変数redirectToのrouter.query[’redirect_to’]の部分は現在のURLパスにredirect_toというクエリパラメータが含まれているかどうかを確認しており、redirect_toというパラメータが存在すればそのパスが、存在しなければ(nullまたはundefinedの場合は)、/が代入されるようになっています。

このページは/singinというパスで表現されます。ここにredirect_toというクエリパラメータが含まれるというのは、/signin?redirect_to=/何らかのパス という状態になっているということですが、この場合においてサインインフォームに適切なユーザ名とパスワードが入力され、submitボタンがクリックされると/ではなく/何らかのパスに遷移するということですね。

このredirect_toのクエリパラメータが設定されるタイミングはどんな時だろうと思って調べてみると、このサンプルアプリケーションには以下のようなファイルが定義されていました。
utils/hooks.ts

import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { useAuthContext } from 'contexts/AuthContext'

export const useAuthGuard = (): void => {
  const router = useRouter()
  const { authUser, isLoading } = useAuthContext()

  useEffect(() => {
    // ユーザーが取得できない場合はサインインページにリダイレクト
    if (!authUser && !isLoading) {
      const currentPath = router.pathname

      router.push({
        pathname: '/signin',
        query: {
          redirect_to: currentPath,
        },
      })
    }
  }, [router, authUser, isLoading])
}

ここで定義されているuseAuthGuardではサインインしていないユーザーが認証が必要なページにアクセスしようとした場合に、サインインページへリダイレクトする処理を行っています。
このリダイレクトの時にrouter.push を使って redirect_to クエリパラメータに現在のページ(ユーザーがアクセスしようとしていたページ)のパスを含めています。

例えば、サインインしていないユーザーが、認証が必要な /dashboard というページにアクセスしようとした場合、useAuthGuard によって /signin?redirect_to=/dashboard にリダイレクトされることとなります。
そして、このリダイレクト発生後に、適切にサインイン処理が行われた場合は、/ではなく、/dashboardに遷移させるというのが、pages/signin.tsxで実装されているhandleSigninの処理となります。

utilsディレクトリからロジックを追いかけ始めましたが、一周回って再びutilsに戻ってきました。
このutils/hooks.tsuseAuthGuardですが、明示的にインポートしている箇所がなかなか見つからず、どこでどのように呼び出されているのかについてはまだはっきりと把握できていない状態です。
こちらも調査してみようと思いましたが、ここまでの作業でヘトヘトになってしまったので、また別の機会にしたいと思います。(おそらくどこかのcontextに組み込まれているか、_app.tsxなど上流のコンポーネントのはず…)

おわりに

今回、学習の一環として進捗しているアプリケーション開発では、開発のフェーズが進むにつれて、コンポーネントの粒度が大きくなり、ロジックも複雑化するため、迷子になってしまったように感じていました。しかし、このように一つ一つ確認してみることで、まだまだ少しではありますが、コードの見通しが立ったように思います。
慣れないうちは、このように立ち止まりながら自分の理解の現在地を確認しつつ進めていこうと思います。

参考資料

本記事を書くにあたっては書籍のほか以下の記事、リンク先を参考にさせていただきました。
サンプルアプリケーションのソースコード
Next.js アプリのディレクトリ構成を考える(Atomic Design と Presentational and Container Components)

Discussion