React(Next.js)のStatic Exports設定で認証ロジックを実装する③

[株式会社 ONE WEDGE](https://onewedge.co.jp/)の[@Shankou](https://qiita.com/Shankou)です。
前回に引き続き、認証ロジックの実装を行っていきたいと思います。

# featuresディレクトリ構成

`React(Next.js)`初心者が最初に迷うのが「ディレクトリ構成はどうすればいいのか」ということだと思います。
かく言う自分がその状態になっていろいろ調べたのですが、今回は`featuresディレクトリ構成`でやりたいと思います。
`featuresディレクトリ構成`というのはライブラリ(ReactやNext.js)から見た属性でディレクトリを切るのではなく、機能に着目してディレクトリを切っていきましょうという考え方です。

ライブラリから見た属性でディレクトリを切ると以下の用になると思います。

```
ｓｒｃ/
 ├ app/
 ├ components/
 │ └ LoginForm.tsx
 ├ hooks/
 ├ providers/
 │ └ AuthProvider.ts
 └ types/
  ├ LoginForm.type.ts
  ├ LoginFormRequest.type.ts
  └ LoginFormResponse.type.ts
```

これを`featuresディレクトリ構成`にすると以下の通りです。

```
ｓｒｃ/
 ├ app/
 ├ components/
 ├ features/
 │ ├ Auth/
 │ │ ├ components
 │ │ │ └ LoginForm.tsx
 │ │ ├ hooks
 │ │ ├ providers/
 │ │ │ └ AuthProvider.ts
 │ │ └ types/
 │ │  ├ LoginForm.type.ts
 │ │  ├ LoginFormRequest.type.ts
 │ │  └ LoginFormResponse.type.ts
 ├ hooks/
 ├ providers/
 └ types/
```

ぱっと見は何が違うのかと思われるかもしれませんが、効果を発揮するのは機能が増えたときです。
最初に示した構成で`componentsディレクトリ`を例にすると、いろいろな機能のコンポーネントファイルがごった煮状態で配置されることになります。
そんな状態では目的のファイルを探すのは一苦労です。関連ファイルもいろいろな場所でごった煮になっています。

これを`featuresディレクトリ構成`にして、機能ごとに階層を作るととても見通しが良くなると思いませんか？
src直下の`componentsディレクトリ`等は共通で利用するものを配置してください。
これで、機能が増えていっても視認性をキープすることができます。

以上、`featuresディレクトリ構成`の簡単な解説でした。
参考にしてみてください。

# .env
.envファイルはプロジェクト直下に作成してください。
まずはAPIの呼び出し先を設定していきます。
今回はフロントのみなので実際にAPIを呼び出すところまでやりませんが、設定のやり方としてまとめていきます。

以前説明した通り、フロント側で使う設定値には`NEXT_PUBLIC_`のプレフィックスが必須になります。
また、通常JavascriptはインラインスクリプトとしてHtmlファイル内に埋め込まれます。
インラインスクリプトが禁止されている場合もあるので、そういった場合は`INLINE_RUNTIME_CHUNK=false`を設定しておくことで、Javascriptを外部ファイルとして出力するようにビルドしてくれます。

```
INLINE_RUNTIME_CHUNK=false
NEXT_PUBLIC_API_BASE_URL="https://api.dev.sample.com/v1"
```

前回軽く説明しましたが、環境ごとにAPIの呼び出し先を変えたい場合は
`.env.stg`等を作成して、専用のビルドコマンドを`package.json`に用意してあげましょう。

```javascript
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "build:dev": "env-cmd -f .env.dev next build",
    "build:stg": "env-cmd -f .env.stg next build",
    "build:prd": "env-cmd -f .env.prd next build",
    "start": "next start",
    "lint": "next lint",
  },
```

# src/config/index.ts
`src/config/index.ts`を作成してください。
.envの設定値は`process.env.XXXX`でいつでも呼び出すことはできます。
ですが、直接呼び出すのではなく`config`として読み込ませてから使用するのが柔軟性のあるやり方です。

```typescript:src/config/index.ts
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL as string
```

# src/libs/myAxios.ts
`src/libs/myAxios.ts`を作成してください。
インスタンスの初期設定値を入れていきます。
将来的には`interceptors`を使って割り込み処理を設定していきます。

```typescript:src/libs/myAxios.ts
import { API_BASE_URL } from '@/config'
import axios, { AxiosInstance } from 'axios'

const createMyAxios = (): AxiosInstance => {
  const instance = axios.create({
    baseURL: API_BASE_URL,
    headers: {},
  })

  return instance
}

const myAxios = createMyAxios()
export default myAxios
```

# src/app/layout.tsx
`src/app/layout.tsx`はそのまま利用できるかと思いますが、適宜修正してください。

```react:src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: '',
  description: '',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang='ja'>
      <body className={`min-h-screen bg-white ${inter.className}`}>
        {children}
      </body>
    </html>
  )
}
```

# src/features/Auth/types/request/PostLoginRequest.type.ts
LoginRequest用の型定義ファイルになります。

```typescript:src/features/Auth/types/request/PostLoginRequest.type.ts
import { z } from 'zod'

export const zPostLoginRequest = z.object({
  email: z.string(),
  password: z.string(),
})
export type PostLoginRequest = z.infer<typeof zPostLoginRequest>
```

# src/features/Auth/types/response/PostLoginResponse.type.ts
LoginResponse用の型定義ファイルになります。

```typescript:src/features/Auth/types/response/PostLoginResponse.type.ts
import { z } from 'zod'

export const zPostLoginResponse = z.object({
  accessToken: z.string(),
  refreshToken: z.string(),
})
export type PostLoginResponse = z.infer<typeof zPostLoginResponse>
```

# src/components
簡単なコンポーネントを作成していきます。
機能に依存しない共通コンポーネントは`src/components/`以下に作成していきます。

まずは`src/components/ErrorMessage.tsx`から。

```react:src/components/ErrorMessage.tsx
import React, { ReactElement, ReactNode } from 'react'

type ErrorMessageProps = {
  children?: ReactNode | undefined
  className?: string | undefined
}

export default function ErrorMessage({ children = undefined, className = '' }: ErrorMessageProps): ReactElement {
  return <p className={`h-6 text-red-600/80 ${className}`}>{children}</p>
}
```

ここで、前回インストールを忘れてしまったライブラリを一つインストールします。

https://www.npmjs.com/package/react-hook-form

`react-hook-form`はフォーム周りのバリデーションロジック等のサポートをしてくれるライブラリとなります。

```
npm i react-hook-form
```

インストールができたら、`src/components/elements/CustomInputField.tsx`を作成しましょう。
すこし特殊なのが、`ComponentPropsWithoutRef`というものを利用していることでしょうか。
Refを親から受け取るようなコンポーネントには必要な書き方となります。

```react:src/components/elements/CustomInputField.tsx
import React, { ComponentPropsWithoutRef, ReactElement, forwardRef } from 'react'
import { ChangeHandler } from 'react-hook-form'
import ErrorMessage from '@/components/ErrorMessage'

type CustomInputFieldProps = {
  id: string
  type?: string | undefined
  name?: string | undefined
  placeholder?: string | undefined
  defaultValue?: string | undefined
  autoComplete?: string | undefined
  label?: string | undefined
  className?: string | undefined
  error?: string | undefined
  onBlur?: ChangeHandler | undefined
  onChange?: ChangeHandler | undefined
}

type CustomInputFieldPropsWithoutRef = ComponentPropsWithoutRef<'input'> & CustomInputFieldProps

function CustomInputField(
  {
    id,
    type = 'text',
    name = '',
    placeholder = '',
    defaultValue = '',
    autoComplete = undefined,
    label = '',
    className = '',
    error = undefined,
    onBlur = undefined,
    onChange = undefined,
  }: CustomInputFieldProps,
  ref: React.ForwardedRef<HTMLInputElement>
): ReactElement {
  return (
    <div className={`flex flex-col text-start ${className}`}>
      <label htmlFor={id} className='font-extrabold'>
        {label}
      </label>
      <input
        type={type}
        name={name}
        id={id}
        placeholder={placeholder}
        defaultValue={defaultValue}
        autoComplete={autoComplete}
        onBlur={onBlur}
        onChange={onChange}
        ref={ref}
        className={`mt-2 p-2 bg-slate-200/60 border outline-none ${error ? 'border-red-600/80' : ''}`}
      />
      <ErrorMessage>{error}</ErrorMessage>
    </div>
  )
}

export default forwardRef<HTMLInputElement, CustomInputFieldPropsWithoutRef>(CustomInputField)
```

# src/app/()/login/page.tsx
`src/app/()/login/page.tsx`を作成してください。
ログインページです。

`react-hook-form`を使用することで、registerでフォームのバリデーションチェックを簡単に設定することができます。
先程作成したコンポーネントと組み合わせて、入力エラーメッセージもバッチリです。
handleSubmitはバリデーションチェックが通った場合にのみonSubmitを実行してくれます。
本来は`LoginForm`としてコンポーネントに切り出すべきですが、今回は手を抜きます！

```react:src/app/()/login/page.tsx
'use client'

import CustomInputField from '@/components/elements/CustomInputField'
import { PostLoginRequest, zPostLoginRequest } from '@/features/Auth/types/request/PostLoginRequest.type'
import { PostLoginResponse } from '@/features/Auth/types/response/PostLoginResponse.type'
import { useRouter } from 'next/navigation'
import { ReactElement } from 'react'
import { useForm, SubmitHandler } from 'react-hook-form'

export default function Login(): ReactElement {
  const route = useRouter()
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<PostLoginRequest>()
  const onSubmit: SubmitHandler<PostLoginRequest> = async (data) => {
    try {
      const params: PostLoginRequest = zPostLoginRequest.parse(data)
      /** 本来であればここでAPIを呼び出します **/
      // const result: PostLoginResponse | false = await myAxios.post('/login', params)
      //   .then((res) => zPostLoginResponse.parse(res))
      //   .catch(() => false)

      // if (result === false) {
      //   // APIエラー処理
      //   return
      // }

      /** 今回はフロント周りの動作のみのため、一旦適当なデータを用意 **/
      const result: PostLoginResponse = { accessToken: 'hogehoge', refreshToken: 'fugafuga' }
      sessionStorage.setItem('accessToken', result.accessToken)
      localStorage.setItem('refreshToken', result.refreshToken)
      route.push('/mypage')
    } catch (e) {
      console.error(e)
      // エラー処理
    }
  }

  return (
    <>
      <h1>ログイン</h1>
      <div className='flex justify-center'>
        <form className='m-2 w-96' onSubmit={handleSubmit(onSubmit)}>
          <CustomInputField
            type='email'
            id='login-id'
            label='メールアドレス'
            className='mt-2'
            error={errors.email?.message}
            {...register('email', {
              required: 'メールアドレスを入力してください',
            })}
          />
          <CustomInputField
            type='password'
            autoComplete='current-password'
            minLength={8}
            id='password'
            label='パスワード'
            className='mt-2'
            error={errors.password?.message}
            {...register('password', {
              required: 'パスワードを入力してください',
              minLength: {
                value: 8,
                message: 'パスワードは8文字以上で入力してください',
              },
            })}
          />
          <button type='submit' className='mt-6 p-4 w-full border bg-slate-200/60'>ログイン</button>
        </form>
      </div>
    </>
  )
}
```

実際はonSubmitでAPIを呼び出すのですが、そっちの実装までは行わないのでとりあえずそれっぽく動作するようにしています。

# src/app/(authenticated)/mypage/page.tsx
最後に遷移先となるmypageを用意してあげましょう。

```react:src/app/(authenticated)/mypage/page.tsx
'use client'

import { ReactElement } from 'react';

export default function Mypage(): ReactElement {
  return (
    <>
      <h1>Mypage</h1>
      <div></div>
    </>
  )
}
```

ここまでできたら`npm run dev`でローカル起動をしてみましょう。
`/login`にアクセスするとログイン画面が表示され、メールアドレスっぽいものとパスワードっぽいものを入力してログインっぽいものができると思います。

![スクリーンショット 2024-09-26 12.14.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3639915/806868d9-e76c-4ff5-8b92-dde7d90a9f18.png)

# まだまだ続く
これでログインっぽい動作をするところまでができました。
今のディレクトリ構成はこんな感じです！

![スクリーンショット 2024-09-25 19.01.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3639915/1ae3723a-8960-3de2-bf53-bb4e66a251be.png)

次回はAuthProviderを作成して、認証済み専用ページのアクセス制限を実施していきます。

next > [④]()