ロジックを追いながらNext.jsのディレクトリ構成について理解を深めてみる
現在は書籍に沿ってサンプルアプリケーションの開発を進捗しています。
このアプリケーション開発では、コンポーネント開発の手法としてAtomic Designというコンポーネントを最小単位から段階的に構築していく考え方が取り入れられています。
開発の序盤はatomsやmoleculesといった細かい粒度のコンポーネントの実装を行い、成果物をStorybookで都度確認するという流れが続き、比較的順調に進められましたが、より大きなコンポーネント(OrganismsやPages)を作る段階になってくると、ロジックが複雑化し、処理の流れが追いきれなくなってきました。
処理の流れが追いきれなくなってきた原因の一つに、その役割がいまいちわからないディレクトリおよびファイルが増えてきたことがあると感じました。
components
以外のディレクトリ、特にutils
やservices
といったディレクトリのファイルではアプリケーションの各所で再利用される関数が宣言されており、それらがどこでどのように呼び出されているのかがわかりにくいという状態になってしまいました。
そのため、ここではutils
やservices
配下で定義されているロジックの処理を追いかけながらコードの見通しを立てていきたいと思います。
なお、今回確認していくサンプルアプリケーションのソースコードは以下で見ることができます。
まずは、ディレクトリ構成を確認します。
src
配下にはcomponents
, containers
, contexts
, pages
, services
, themes
, types
, utils
というディレクトリが配置されています。
先述の通り、この中で自分が混乱する原因となっているutils
とservices
のそれぞれのディレクトリの役割について整理するところから始めたいと思います。
utils
とservices
の違い
どちらのディレクトリにも関数が定義されたファイルが格納されており、別のファイルからそれらの関数をインポートして利用するという構成になっています。
これらのディレクトリを使い分ける上での線引きには調べてみたところ諸説あるよう(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
には?
をつけています。 - それぞれの引数の型には
RequestInfo
、RequestInit
というものが付与されていますが、これらはTypeScriptの標準ライブラリに組み込まれたFetch APIのリクエストを型安全に扱うための型定義となります。 -
RequestInfo
はstring
またはRequest
オブジェクトのいずれかを受け取ります。 -
RequestInit
はリクエスト時のオプションを表すオブジェクトとなります。
繰り返しますが、fetcher
関数の役割としてはレスポンスが200以外の場合はエラーを投げるのみです。
では次に、このfetcher
関数がどのように利用されているのかを追いかけてみます。
fetcher
関数を追う
このサンプルアプリケーションにおいて、fetcher
関数が利用されているのはすべてservices
配下のファイルでした。
services
配下にはauth
, products
, users
というフォルダが用意されており、その配下に、signin
やsignout
などの認証系の処理、get-all-products
やget-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
型の値であるusername
とpassword
を受け取ります。
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
, isLoading
、signin
、signout
, mutate
が呼び出せるようになっています。
signin
関数の第一引数のcontext
はこのAuthContextProvider
の定義の中であらかじめ設定されているので、AuthContextProvider
以下の子コンポーネントでsignin
関数を呼び出す場合はusername
とpassword
の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
を使って、username
とpassword
それぞれに入力必須のバリデーション設定した入力フォーム(「見た目」)を構成しています。入力値のバリデーションチェックまでは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.ts
のuseAuthGuard
ですが、明示的にインポートしている箇所がなかなか見つからず、どこでどのように呼び出されているのかについてはまだはっきりと把握できていない状態です。
こちらも調査してみようと思いましたが、ここまでの作業でヘトヘトになってしまったので、また別の機会にしたいと思います。(おそらくどこかのcontextに組み込まれているか、_app.tsxなど上流のコンポーネントのはず…)
おわりに
今回、学習の一環として進捗しているアプリケーション開発では、開発のフェーズが進むにつれて、コンポーネントの粒度が大きくなり、ロジックも複雑化するため、迷子になってしまったように感じていました。しかし、このように一つ一つ確認してみることで、まだまだ少しではありますが、コードの見通しが立ったように思います。
慣れないうちは、このように立ち止まりながら自分の理解の現在地を確認しつつ進めていこうと思います。
参考資料
本記事を書くにあたっては書籍のほか以下の記事、リンク先を参考にさせていただきました。
サンプルアプリケーションのソースコード
Next.js アプリのディレクトリ構成を考える(Atomic Design と Presentational and Container Components)
Discussion