個人開発（フロント）

# はじめに
今回、以下の様な技術stackで個人開発を行ったので、備忘録として残そうと思います

- Go(API)
- Next.js・TypeScript(フロント)
- AWS・Terraform(インフラ)
- github actions(CI/CD)

本記事では、**フロント側**の取り組み内容について触れたいと思います
バックエンド(API)側、インフラ・CI/CD側の記事については以下に置いておきます。

### バックエンド側
https://qiita.com/__hawk__/items/889467c34032bd76478d

### インフラ・CI/CD側
https://qiita.com/__hawk__/items/4ec582d65a8d6edb0a1b

# github repository
### アプリケーション側
https://github.com/hawk0616/recruit-service-board

### インフラ側
https://github.com/hawk0616/recruit-service-board-infla

# 各version
- node: 16.17.0

# ディレクトリ構成
※ next.jsの細かいファイルは省きます

<details>
<summary>アプリディレクトリ</summary>

- src
    - commponents
        - comment
            - CommentForm.tsx
            - GetComments.tsx
        - search
            - SearchButton.tsx
            - SearchForm.tsx
        - user
            - LoginFrom.tsx
            - SignUpFrom.tsx
        - BorderLine.tsx
        - Company.tsx
        - Error.tsx
        - LikeButton.tsx
        - LoadingSpinner.tsx
        - TechnologyTag.tsx
    - hooks
        - useAuth.ts
        - useComment.ts
        - useError.ts
        - useGetCompanies.ts
        - useGetCompanyBiId.ts
        - UseGetTechnologyByCompanyId.ts
        - useLike.ts
        - useSearchCompany.ts
    - pages
        - company
            - [id].tsx
            - inndex.tsx
        - _app.tsx
        - _document.tsx
        - 404.tsx
        - auth.tsx
        - ndex.tsx
    - styles
        - global.css
- package.json
</details>

# ユーザー認証部分
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1196616/b63669d1-15f9-b74f-b514-ec449f88369e.png)

ここでは認証部分に関してフロント部分の実装を説明していきます

```ts:useAuth.ts
import { useState } from 'react';
import axios from 'axios';
import { useError } from './useError'

export const useLike = () => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState(null);
  const { ErrorHandling } = useError();
  
  const createLike = async (companyId: number) => {
    setLoading(true);
    try {
      const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/companies/likes`, {companyId}, { withCredentials: true })
      setLoading(false);
    } catch (err: any) {
      if (err.response.data.message) {
        ErrorHandling(err.response.data.message)
      } else {
        ErrorHandling(err.response.data)
      }
      setError(err)
      setLoading(false)
    }
  };

  const deleteLike = async (companyId: number) => {
    setLoading(true);
    try {
      const response = await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/companies/likes/${companyId}`, { withCredentials: true });
    } catch (err: any) {
      if (err.response.data.message) {
        ErrorHandling(err.response.data.message);
      } else {
        ErrorHandling(err.response.data);
      }
      setError(err);
    }
    setLoading(false);
  };

  const checkLike = async (companyId: number): Promise<boolean | undefined> => {
    setLoading(true);
    try {
      const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies/likes/${companyId}`, { withCredentials: true });
      setLoading(false);
      return response.data.liked;
    } catch (err: any) {
      if (err.response.data.message) {
        ErrorHandling(err.response.data.message);
      } else {
        ErrorHandling(err.response.data);
      }
      setError(err);
      setLoading(false);
    }
  };
  
  return { 
    createLike,
    deleteLike,
    checkLike,
    loading,
    error
  };
};
```

上記は「ログイン」、「サインアップ」、「ログアウト」のAPIをフェッチングし、関数として機能をexportするカスタムフックです。上記で定義した各関数を下記のようなコンポーネントで使用していきます。

```ts:SignupForm.tsx
import { useState, FormEvent } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { useError } from '@/hooks/useError';

const SignupForm = ({ switchToLogin }: { switchToLogin: () => void }) => {
  const [name, setName] = useState<string>('');
  const [email, setEmail] = useState<string>('');
  const [password, setPassword] = useState<string>('');
  const { signup, login } = useAuth();
  const { ErrorHandling } = useError();

  const submitSignupHandler = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    try {
      await signup({ name, email, password });
      await login({ name, email, password });
    } catch (err: any) {
      ErrorHandling(err.response?.data?.message || err.message || "something went wrong");
    }
  };

  return (
    <form onSubmit={submitSignupHandler} className="w-full max-w-2xl">
      <div>
        <input
          className="mb-4 px-4 text-lg py-3 border border-gray-300 w-full"
          name="name"
          type="text"
          placeholder="ユーザー名"
          onChange={(e) => setName(e.target.value)}
          value={name}
        />
      </div>
      <div>
        <input
          className="mb-4 px-4 text-lg py-3 border border-gray-300 w-full"
          name="email"
          type="email"
          placeholder="メールアドレス"
          onChange={(e) => setEmail(e.target.value)}
          value={email}
        />
      </div>
      <div>
        <input
          className="mb-4 px-4 text-lg py-3 border border-gray-300 w-full"
          name="password"
          type="password"
          placeholder="パスワード"
          onChange={(e) => setPassword(e.target.value)}
          value={password}
        />
      </div>
      <div className="flex justify-between items-center">
        <button
          className="py-3 px-6 text-lg rounded text-white bg-indigo-600"
          disabled={!name || !email || !password}
          type="submit"
        >
          新規登録
        </button>
        <span className="text-indigo-600 cursor-pointer" onClick={switchToLogin}>
          ログインはこちら
        </span>
      </div>
    </form>
  );
}

export default SignupForm;
```

こうすることで動的にブラウザでユーザー認証機能を表示することができます。


# 企業一覧
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1196616/843ce412-d140-13db-f6f5-4b6142f678c6.png)

続いて、`ドメイン名://company`のURLで表示される企業一覧画面です
ここでは大きく分けて以下のような機能が提供されます
- 企業一覧の表示
- ログアウト
- 企業検索

```ts:useGetCompanies.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Company } from '../types/company'
import { useError } from './useError'



export const useGetCompanies = () => {
  const [companies, setCompanies] = useState<Company[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState(null);
  const { ErrorHandling } = useError()

  useEffect(() => {
    const fetchCompanies = async () => {
      try {
        const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies`, { withCredentials: true })
        setCompanies(response.data)
        setLoading(false)
      } catch (err: any) {
        if (err.response.data.message) {
          ErrorHandling(err.response.data.message)
        } else {
          ErrorHandling(err.response.data)
        }
        setError(err)
        setLoading(false)
      }
    };

    fetchCompanies();
  }, [ErrorHandling]);

  return { companies, loading, error };
};
```
上記ではGo(API)側の`GetAllCompanies`メソッドに対してgetリクエストをしています。
ここで定義した関数をカスタムフックとして、`pages/company/index.tsx`で用いています。

```tsx:pages/company/index.tsx
import Link from 'next/link';
import { useGetCompanies } from '../../hooks/useGetCompanies';
import { Company } from '../../types/company'
import LoadingSpinner from '../../components/LoadingSpinner';
import SearchForm from '@/components/search/SearchForm';
import SearchButton from '@/components/search/SearchButton';
import { useState } from 'react';
import useSearchCompany from '@/hooks/useSearchCompany';
import LogoutButton from '@/components/user/LogoutButton';

const CompanyPage = () => {
  const { companies, loading } = useGetCompanies();
  const [searchInput, setSearchInput] = useState<string>(''); 
  const [searchQuery, setSearchQuery] = useState<string>(''); 
  const { companies: searchedCompanies, loading: searchLoading, setShouldSearch } = useSearchCompany(searchQuery);

  if (loading || searchLoading) {
    return <LoadingSpinner />;
  }

  const handleSearch = () => {
    setSearchQuery(searchInput);
    setShouldSearch(true);
  };

  const displayCompanies = searchQuery ? searchedCompanies : companies;


  return (
    <div className="bg-gray-200 p-6 rounded-lg shadow-md">
      <div className="absolute top-4 right-4"><LogoutButton /></div>
      <h1 className="text-black text-5xl font-bold tracking-wide mb-12">企業一覧</h1>
      <div className="mb-36">
        <SearchForm onSearch={(query) => setSearchQuery(query)} />
        <SearchButton onClick={handleSearch} />
      </div>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
        {displayCompanies.map((company: Company) => (
          <Link key={company.id} href={`/company/${company.id}`} passHref>
            <div className="block bg-white p-4 rounded shadow hover:bg-gray-100 transition cursor-pointer">
              <h2 className="text-xl font-semibold mb-2">{company.name}</h2>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
};

export default CompanyPage;

```

続いて、企業検索に関しては以下のようなhooksを定義しています。

```ts:useSearchCOmpany.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Company } from '../types/company';
import { useError } from './useError';

const useSearchCompany = (name: string) => {
  const [companies, setCompanies] = useState<Company[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);
  const [shouldSearch, setShouldSearch] = useState<boolean>(false);
  const { ErrorHandling } = useError();

  useEffect(() => {
    if (!shouldSearch){
      setLoading(false)
      return;
    }

    const fetchData = async () => {
      try {
        const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies/search?name=${name}`, { withCredentials: true });
        setCompanies(response.data);
        setLoading(false);
      } catch (err: any) {
        if (err.response.data.message) {
          ErrorHandling(err.response.data.message)
        } else {
          ErrorHandling(err.response.data)
        }
        setError(err)
        setLoading(false)
      }
    };

    fetchData();
    setShouldSearch(false);
  }, [name, shouldSearch, ErrorHandling]);

  return { companies, loading, error, setShouldSearch };
};

export default useSearchCompany;
```

`name`パラメータに検索条件が入っており、これをリクエストパラメータに含めることで、DBに一致する企業を`companies`という配列変数としてフロントで受け取り、それを返す関数である。

このhooksは上記同様で`pages/company/index.tsx`で使用される。


# 企業詳細画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1196616/a1a15534-2a87-953f-7b56-bf420fd2948c.png)

次に、企業詳細ページです
`ドメイン名://company/:id`でアクセスできるページです
このページで提供している機能はAPI単位で言うと、以下のようです

- 企業情報
- いいねを押したかどうか
- 企業が保有する技術情報
- コメント全件
- コメント投稿フォーム(次のセクションで説明)

企業詳細に関しては以下のようなhooksを定義してAPIにアクセスしています

```ts:useGetCompaniesById.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Company } from '../types/company'
import { useError } from './useError'
import { useRouter } from 'next/router';

export const useGetCompanyById = () => {
  const [company, setCompany] = useState<Company>();
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState(null);
  const { ErrorHandling } = useError();
  const { query } = useRouter();

  useEffect(() => {
    const fetchCompanyById = async () => {
      if (!query.id) return;

      try {
        const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies/${query.id}`, { withCredentials: true })
        setCompany(response.data)
        setLoading(false)
      } catch (err: any) {
        if (err.response && err.response.data && err.response.data.message) {
          ErrorHandling(err.response.data.message);
        } else if (err.response && err.response.data) {
          ErrorHandling(err.response.data);
        } else {
          ErrorHandling(err.message || "An unexpected error occurred.");
        }
        setError(err)
        setLoading(false)
      }
    };

    fetchCompanyById();
  }, [query.id, ErrorHandling]);

  return { company, loading, error };
};
```
Next.jsのhooksであるう`useRouter`を使用して、idを動的に取得し、APIのリクエストパラメータに含めます

pagesでの表示は以下のようです
```tsx:pages/company/[id].tsx
import Link from 'next/link';
import { useGetCompanyById } from '@/hooks/useGetCompanyById';

import { useEffect } from 'react';
import { useRouter } from 'next/router';

import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft } from "@fortawesome/free-solid-svg-icons";

import BorderLine from '../../components/BorderLine';
import LoadingSpinner from '@/components/LoadingSpinner';
import TechnologyTags from '@/components/TechnologyTags';
import LikeButton from '@/components/LikeButton';
import { CommentForm } from '@/components/comment/CommentForm';
import CommentsList from '@/components/comment/GetComments';

const CompanyDetailPage = () => {
  const { company, loading } = useGetCompanyById();
  const router = useRouter();

  useEffect(() => {
    if (!loading && !company?.id) {
      router.push('/404');
    }
  }, [company, loading, router]);


  if (loading) {
    return <LoadingSpinner />;
  }

  return (
    <div className="bg-gray-200 min-h-screen flex flex-col items-start justify-start p-6 relative">
      <Link href="/company" passHref>
        <FontAwesomeIcon icon={faArrowLeft} size="2x" className="absolute top-4 left-4 text-gray-600 hover:underline" />
      </Link>

      <div className="m-8 flex items-center">
        <h1 className="text-gray-800 text-4xl font-bold tracking-wide">{company?.name}</h1>
        <LikeButton companyId={company?.id} className="ml-4" />
      </div>

      <div className="m-8">
        <h2 className="text-gray-600 text-xl font-semibold">企業情報</h2>
        <BorderLine />
        <div className="relative mb-4 w-full max-w-7xl">
          <h2 className="text-gray-600 font-semibold mb-2 absolute top-0 left-0 px-2">事業内容</h2>
          <p className="text-gray-600 pl-28">{company?.description || "-"}</p>
        </div>
        <div className="relative mb-4 w-full max-w-7xl">
          <h2 className="text-gray-600 font-semibold mb-2 absolute top-0 left-0 px-2">OpenSalary</h2>
          <a href={company?.open_salary} target="_blank" rel="noopener noreferrer" className="text-gray-500 pl-28 underline hover:text-gray-700">
            {company?.open_salary || "-"}
          </a>
        </div>
        <div className="relative mb-4 w-full max-w-7xl">
          <h2 className="text-gray-600 font-semibold mb-2 absolute top-0 left-0 px-2">所在地</h2>
          <p className="text-gray-600 pl-28">{company?.address || "-"}</p>
        </div>
      </div>

      <div className="m-8">
        <h2 className="text-gray-600 text-xl font-semibold">技術情報</h2>
        <BorderLine />
        <TechnologyTags />
      </div>

      <div className="m-8">
        <h2 className="text-gray-600 text-xl font-semibold">ユーザーコメント</h2>
        <BorderLine />
        {company?.id && <CommentsList companyId={company?.id} />}
        {company?.id && <CommentForm companyId={company.id} />}
      </div>
    </div>
  );
};

export default CompanyDetailPage;
```

いいねしているかどうかに関して、以下のhooksを定義しています

```ts:useLike.ts
import { useState } from 'react';
import axios from 'axios';
import { useError } from './useError';

export const useLike = () => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  const { ErrorHandling } = useError();

  const handleAxiosError = (err: any) => {
    let errorMessage = 'An error occurred.';
    if (err.response && err.response.data.message) {
      errorMessage = err.response.data.message;
    } else if (err.response && err.response.data) {
      errorMessage = err.response.data;
    } else if (err.message) {
      errorMessage = err.message;
    }
    ErrorHandling(errorMessage);
    setError(errorMessage);
    setLoading(false);
  }

  const createLike = async (companyId: number) => {
    setLoading(true);
    try {
      await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/companies/likes`, { companyId }, { withCredentials: true });
      setLoading(false);
    } catch (err: any) {
      handleAxiosError(err);
    }
  };

  const deleteLike = async (companyId: number) => {
    setLoading(true);
    try {
      await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/companies/likes/${companyId}`, { withCredentials: true });
      setLoading(false);
    } catch (err: any) {
      handleAxiosError(err);
    }
  };

  const checkLike = async (companyId: number): Promise<boolean | undefined> => {
    setLoading(true);
    try {
      const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies/likes/${companyId}`, { withCredentials: true });
      setLoading(false);
      return response.data.liked;
    } catch (err: any) {
      handleAxiosError(err);
    }
  };

  return {
    createLike,
    deleteLike,
    checkLike,
    loading,
    error
  };
};
```

ここでは、主に3つのAPIを受け取ります。
`createLike`はGo側のAPIに対して、postメソッドを送ります
`deleteLike`はGo側のAPIに対して、deleteメソッドを送ります
'checkLike'はGo側のAPIに対して、getメソッドを送り、bool値を受け取ります

いいねボタンを以下のコンポーネントで使用しています

```tsx:LikeButton.tsx
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHeart } from "@fortawesome/free-solid-svg-icons";
import { useLike } from '@/hooks/useLike';
import LoadingSpinner from './LoadingSpinner';
import { useError } from '@/hooks/useError';

type LikeButtonProps = {
  companyId?: number;
  className?: string;
};

const LikeButton: React.FC<LikeButtonProps> = ({ companyId, className }) => {
  const [liked, setLiked] = useState<boolean | undefined>(false);
  const { createLike, deleteLike, checkLike, loading } = useLike();
  const { ErrorHandling } = useError();

  useEffect(() => {
    const fetchLikeStatus = async () => {
      if (companyId) {
        const isLiked = await checkLike(companyId);
        setLiked(isLiked);
      }
    };

    fetchLikeStatus();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [companyId]);

  const toggleLike = async () => {
    if (!companyId || loading) return;

    try {
      if (liked) {
        await deleteLike(companyId);
      } else {
        await createLike(companyId);
      }
      setLiked(!liked);
    } catch (err: any) {
      ErrorHandling(err.message);
    }
  };

  if (liked === null) {
    return <LoadingSpinner />;
  }

  if (!companyId) {
    return (
      <div className="text-gray-400 cursor-not-allowed">
        <FontAwesomeIcon icon={faHeart} size="2x" />
      </div>
    );
  }

  return (
    <button onClick={toggleLike} className={`like-button-class ${className}`}>
      <FontAwesomeIcon icon={faHeart} size="2x" className={liked ? "text-red-500" : "text-gray-400 hover:text-red-500"} />
    </button>
  );
};

export default LikeButton;
}
```

ここでは、いいねされているかどうかを`useEffect`内で確認し、そのbool値を`useState`で管理します
この値でボタンを押したときにpostメソッドなのか、deleteメソッドなのかを判断し、リクエストをくるようにします。

次に、企業が保有する技術情報については以下のようなhooksを定義しています

```ts:useGetTechnologiesByCompanyId.ts
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Technology } from '../types/companyTechnology'
import { useError } from './useError'
import { useRouter } from 'next/router';

const useGetTechnologiesByCompanyId = () => {
  const [companyTechnologies, setCompanyTechnologies] = useState<Technology[]>([]);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState(null);
  const { ErrorHandling } = useError();
  const { query } = useRouter();

  useEffect(() => {
    const fetchTechnologiesByCompanyId = async () => {
      if (!query.id) return;

      try {
        const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies/${query.id}/company_technologies`, { withCredentials: true })
        setCompanyTechnologies(response.data)
        setLoading(false)
      } catch (err: any) {
        if (err.response && err.response.data) {
          ErrorHandling(err.response.data)
        } else if (err.response) {
          ErrorHandling(err.response)
        } else {
          ErrorHandling(err)
        }
        setError(err)
        setLoading(false)
      }
    };

    fetchTechnologiesByCompanyId();
  }, [query.id, ErrorHandling]);

  return { companyTechnologies, loading, error };
}

export default useGetTechnologiesByCompanyId
```

前述同様、companyIdを`useRouter`で取得して、APIリクエストを送ります
ここで受け取った企業の技術情報の配列データを以下のようなコンポーネントで用いています

```tsx:TechnologyTags.tsx
import React from 'react';
import useGetTechnologiesByCompanyId from '@/hooks/useGetTechnologiesByCompanyId';
import LoadingSpinner from './LoadingSpinner';

const TechnologyTags = ({  }) => {
  const { companyTechnologies, loading } = useGetTechnologiesByCompanyId();

  if (loading) return <LoadingSpinner />;

  if (!companyTechnologies || companyTechnologies.length === 0) {
    return <p>不明</p>;
  }

  return (
    <div className="flex flex-wrap gap-2">
      {companyTechnologies.map((technology, index) => (
        <span key={index} className="bg-green-400 text-white px-3 py-1 rounded-full text-sm">
          {technology.name}
        </span>
      ))}
    </div>
  );
};

export default TechnologyTags;
```

次に、コメント全件表示に関して、以下のようなhooksを定義しています
```ts:useComment.ts
import { useState, useCallback } from 'react';
import axios from 'axios';
import { useError } from './useError'

export const useComment = () => {
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState(null);
  const { ErrorHandling } = useError();
  
  const createComment = async (companyId: number, content: string) => {
    setLoading(true);
    try {
      const response = await axios.post(`${process.env.NEXT_PUBLIC_API_URL}/companies/comments`, {
        companyId,
        content
      }, { withCredentials: true })
      setLoading(false);
      return response.data;
    } catch (err: any) {
      if (err.response.data.message) {
        ErrorHandling(err.response.data.message)
      } else {
        ErrorHandling(err.response.data)
      }
      setError(err)
      setLoading(false)
    }
  };

  const deleteComment = async (companyId: number) => {
    setLoading(true);
    try {
      const response = await axios.delete(`${process.env.NEXT_PUBLIC_API_URL}/companies/comments/${companyId}`, { withCredentials: true });
    } catch (err: any) {
      if (err.response.data.message) {
        ErrorHandling(err.response.data.message);
      } else {
        ErrorHandling(err.response.data);
      }
      setError(err);
    }
    setLoading(false);
  };

  const getCommentsByCompanyId = useCallback(async (companyId: number) => {
    setLoading(true);
    try {
      const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies/comments/${companyId}`, );
      setLoading(false);
      return response.data;
    } catch (err: any) {
      if (err.response && err.response.data.message) {
        ErrorHandling(err.response.data.message);
      } else if (err.response && err.response.data) {
        ErrorHandling(err.response.data);
      } else {
        ErrorHandling(err.message);
      }
      setError(err);
      setLoading(false);
    }
  }, [ErrorHandling]);

  return { 
    createComment,
    deleteComment,
    getCommentsByCompanyId,
    loading,
    error
  };
};
```
※ **コメント削除機能はUI実装していません**

`getCommentsByCompanyId`に関しては、企業に紐づくコメント全件を取ってきます。

このhooksを以下のコンポーネントで使用していきます。

```tsx:GetCOmments.tsx
import { useComment } from '@/hooks/useComment';
import React, { useState, useEffect } from 'react';
import LoadingSpinner from '../LoadingSpinner';
import { Comment } from '@/types/comment';

interface CommentsListProps {
  companyId: number;
}

const CommentsList: React.FC<CommentsListProps> = ({ companyId }) => {
  const { getCommentsByCompanyId, loading } = useComment();
  const [comments, setComments] = useState<Comment[]>([]);

  useEffect(() => {
    let isMounted = true;
  
    const fetchComments = async () => {
      const result = await getCommentsByCompanyId(companyId);
  
      if (isMounted) {
        setComments(result);
      }
    };
    fetchComments();
  
    return () => {
      isMounted = false;
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [companyId]);

  return (
    <div className="space-y-4 p-4">
      {loading && <LoadingSpinner />}

      {comments.length > 0 ? (
        <div className="space-y-4">
          {comments.map((comment, index) => (
            <div key={index} className="p-4 bg-white rounded shadow-md">
              <p className="text-gray-700">{comment.content}</p>
            </div>
          ))}
        </div>
      ) : (
        <p className="text-gray-500">コメントはありません</p>
      )}
    </div>
  );
};

export default CommentsList;

```

次に、コメント投稿機能に関して、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1196616/7e37f0fd-3d9b-8e5f-352e-cad8be762ba8.png)

前述で示した、`createComment`に関してはcompanyIdとcontentをリクエストパラメータに含めてpostリクエストしています。
このhooksを以下のコンポーネントのように使用しています
```tsx:CommentForm.tsx
import { useComment } from '../../hooks/useComment';
import { useState } from 'react'

export const CommentForm = ({ companyId }: { companyId: number }) => {
  const { createComment, loading } = useComment();
  const [content, setContent] = useState<string>('');
  const [showForm, setShowForm] = useState<boolean>(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await createComment(companyId, content);
    setContent('');
  };

  return (
    <div className="space-y-6">
    <button 
        onClick={() => setShowForm(!showForm)}
        className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded my-2"
    >
        コメントする
    </button>

    {showForm && (
        <form onSubmit={handleSubmit} className="space-y-4">
            <textarea 
                value={content} 
                onChange={(e) => setContent(e.target.value)}
                className="w-full h-24 p-2 border rounded my-2 shadow-sm focus:ring focus:ring-opacity-50 focus:ring-blue-300 focus:border-blue-300"
            ></textarea>
            <button 
                type="submit" 
                disabled={loading}
                className={`w-full bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
            >
                投稿
            </button>
        </form>
    )}
</div>

)

};
```

# 最後に(反省と感想)
## 反省
- useEffectの書き方が微妙な気がする
    - こことかもとにかく記述が長い
```
useEffect(() => {
    const fetchCompanies = async () => {
      try {
        const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/companies`, { withCredentials: true })
        setCompanies(response.data)
        setLoading(false)
      } catch (err: any) {
        if (err.response.data.message) {
          ErrorHandling(err.response.data.message)
        } else {
          ErrorHandling(err.response.data)
        }
        setError(err)
        setLoading(false)
      }
    };

    fetchCompanies();
  }, [ErrorHandling]);
```
`useSWR`というhooksがあるらしい

https://www.youtube.com/watch?v=Kb8YbE8NxZ0

→ データのフェッチとキャッシュ管理が楽になりそうな感じ

## 感想
個人開発でNext.js(TypeScript)を触ったのが初めてで、画面を作り切れるかどうかが不安だったが、なんとか作り切れたのは良かったと思う。
ただNext.jsを採用した根拠を明確に持っていなかったと感じている
App Routerを使っていないし、Next.jsらしいSSGやSSRのようなデータハンドリングも行っていない
次の開発ではもっとNext.jsらしさを用いて取り入れていきたいです。