【React / TypeScript】axios と QiitaAPI を使ったサンプルアプリ


# はじめに
検索キーワードから[QiitaAPI](https://qiita.com/api/v2/docs)を叩いて検索結果を一覧表示するアプリを作ってみた。

QiitaAPIで利用したものは、「投稿の一覧を作成日時の降順で取得できる」こちらのAPI　[/api/v2/items](https://qiita.com/api/v2/docs#get-apiv2items)

#### 使用技術
ざっくり

- Next.js
- TypeScript
- axios
- Tailwind CSS

一応このプロジェクトのソースコードっす!!↓↓
https://github.com/TomoyukiMatsuda/React-pra/tree/axios-qiitaapi/next-sample

#### おまけ
後日、このアプリを拡張したものを作成しました。記事にもしていますのでよろしければこちらもご覧ください。
[【React / TypeScript】Recoil での状態管理サンプルアプリ](https://qiita.com/tkmd35/items/b4630e53a907d96e3430)


# こんな感じのものを作りました
フォームに検索ワードを入力して、検索キーワードに該当するQiita記事を取得して一覧表示するアプリ。

![qiitaapi_axios.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/5b5fcffe-e5aa-0bb2-bfc7-ac4d4d9b0d37.gif)



### ローディング中
![スクリーンショット 2021-09-04 8.59.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/b06c32ee-a3bf-8588-beea-f31e7ac7ae0e.png)

### エラー時
画像はオフラインの時のエラーメッセージ

![スクリーンショット 2021-09-04 8.58.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/f4c20d0c-10e0-2fad-ea6f-e14362f09c9a.png)

### 検索結果0件
![スクリーンショット 2021-09-04 9.00.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/d4158c5e-6aaf-29de-61ae-e8224c446e54.png)



## ディレクトリ構成
ディレクトリ構成はこんな感じ
![スクリーンショット 2021-09-04 9.04.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/8c0f4217-bf7e-79da-4756-6aca835dd219.png)


# 各ファイルのコード

## _app.tsx

```_app.tsx
import 'tailwindcss/tailwind.css'
import type { AppProps } from 'next/app'

// 特に何もしていない
function MyApp({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}
export default MyApp
```

## ApiClient（lib/apiClient.tsx）
``apiClient``には``axios``を利用。

``axios.create()``でデフォルトの設定をしておく

- ``baseURL:``APIを叩くときに付与する url の前に付加される url を設定
- ``responseType:``レスポンスの形式を設定（ ``arraybuffer``, ``document``, ``json``, ``text``, ``stream``が指定できるっぽい、ほとんどの場合``json``を指定すると思いますが、、 ）
- ``headers:`` HTTPヘッダーを指定
- ``params:`` 送信するリクエストパラメータを指定。今回はAPI叩くタイミング``hooks/useListQiitaArticles.ts``の中で設定してます。（今回叩くAPI[/api/v2/items](https://qiita.com/api/v2/docs#get-apiv2items)には``page`` ``per_page`` ``query`` を付与できる）

他にも設定がたくさんある。[こちらに詳しく](https://github.com/axios/axios#instance-methods)のってます。

```lib/apiClient.ts
import axios from "axios";

export const apiClient = axios.create({
  baseURL: "https://qiita.com/api/v2",
  responseType: "json",
  headers: {
    "Content-Type": "application/json",
  },
});
```

## 型定義（types/QiitaItem.tsx）

２つの型を定義

- アプリ内で利用するプロパティ（ID、タイトル、LGTM数、ユーザー
）のみの型``QiitaItem``
- APIのレスポンスを受け取るための型``QiitaItemResponse``


```types/QiitaItem.tsx
// アプリ内で利用するためのQiita記事 型定義
export type QiitaItem = Pick<
  QiitaItemResponse,
  "id" | "title" | "likes_count" | "user"
>; // Pick で利用したいプロパティのみを抽出

// Qiita Api レスポンスの型定義
export interface QiitaItemResponse {
  rendered_body: string;
  body: string;
  coediting: boolean;
  comments_count: number;
  created_at: string;
  group: {
    created_at: string;
    description: string;
    name: string;
    private: boolean;
    updated_at: string;
    url_name: string;
  };
  id: string;
  likes_count: number;
  private: boolean;
  reactions_count: number;
  tags: [
    {
      name: string;
      versions: string[];
    }
  ];
  title: string;
  updated_at: string;
  url: string;
  user: {
    description: string;
    facebook_id: string;
    followees_count: number;
    followers_count: number;
    github_login_name: string;
    id: string;
    items_count: number;
    linkedin_id: string;
    location: string;
    name: string;
    organization: string;
    permanent_id: number;
    profile_image_url: string;
    team_only: boolean;
    twitter_screen_name: string;
    website_url: string;
  };
  page_views_count: number;
  team_membership: {
    name: string;
  };
}
```

## API処理のhooks（hooks/useListQiitaArticles.ts）
API叩くフェッチ処理のカスタムフックを作る。
以下をの５つを返す。

- ``articles：`` 取得したQiita記事リストのデータ
- ``searchWord：`` 検索クエリーテキスト
- ``errorMessage：`` エラー時のレスポンスメッセージ
- ``isLoading：`` API叩いているとき、ローディング中かどうかを判断するフラグ
- ``fetchArticles：`` APIを叩く関数

```hooks/useListQiitaArticles.ts
import { Dispatch, FormEvent, SetStateAction, useState } from "react";
import { apiClient } from "../lib/apiClient";
import { QiitaItem, QiitaItemResponse } from "../types/QiitaItem";

export const useListQiitaArticles = () => {
  const [articles, setArticles] = useState<Array<QiitaItem>>([]);
  const [searchWord, setSearchWord] = useState("");
  const [errorMessage, setErrorMessage] = useState<string>("");
  const [isLoading, setIsLoading] = useState(false);

  const fetchArticles = async (
    e: FormEvent<HTMLFormElement>,
    formText: string,
    setFormText: Dispatch<SetStateAction<string>>
  ) => {
    e.preventDefault(); // フォームのデフォルトの動作（リロード）を防ぐ
    setIsLoading(true); // ローディング開始
    setErrorMessage(""); // エラーメッセージを初期化

    // await を付与することでこの処理が終わらない限り次の処理に進まないようになる（これがないとローディング処理などが先に呼ばれてしまう）
    await apiClient
      .get<Array<QiitaItemResponse>>("/items", {
        params: {
          query: formText, // フォーム入力を検索ワードとして設定
          per_page: 25, // 25件 の記事を取得するように設定
        },
      })
      .then((response) => {
        // レスポンスから利用したい要素を QiitaItem 型 の配列でセット
        setArticles(
          response.data.map<QiitaItem>((d) => {
            return {
              id: d.id,
              title: d.title,
              likes_count: d.likes_count,
              user: d.user,
            };
          })
        );

        // 検索キーワードをレスポンスから取得してセット
        setSearchWord(response.config.params.query);
      })
      .catch((error) => {
        // エラーメッセージをセット
        setErrorMessage(error.message);
      });

    setIsLoading(false); // ローディング終了
    setFormText(""); // 最終的にフォーム入力を空にする
  };

  return {
    articles,
    searchWord,
    errorMessage,
    isLoading,
    fetchArticles,
  };
};
```

``axios``でAPIを叩いているところを解説すると
書き方としてはこんな感じになっていて

```tsx
apiClient.httpメソッド指定<レスポンスの型>(baseUrlに続くurl, 設定)
  .then((成功レスポンス) => {
    // 成功
  })
  .catch((エラーレスポンス) => {
    // エラー
  })
```

今回は
HTTPメソッドが``GET``で
この↓↓``url``でリクエストを送信している
``https://qiita.com/api/v2/items?per_page=25&query=react``

※検索ワード（``query: formText``）が「react」の場合


## ページ （pages/index.tsx）

``"/"``ページ``pages/index.tsx``は
フォームコンポーネント（``SearchForm``）の下に記事検索結果を表示するコンポーネント（``ArticleList``）を配置している。

```pages/index.tsx
import React from "react";
import { useListQiitaArticles } from "../hooks/useListQiitaArticles";
import { ArticleList } from "../components/ArticleList";
import { SearchForm } from "../components/SearchForm";

const Home: React.VFC = () => {
  const { articles, searchWord, errorMessage, isLoading, fetchArticles } =
    useListQiitaArticles();

  return (
    <div className="max-w-5xl my-0 mx-auto px-12">
      <SearchForm fetchArticles={fetchArticles} />
      <ArticleList
        articles={articles}
        searchWord={searchWord}
        errorMessage={errorMessage}
        isLoading={isLoading}
      />
    </div>
  );
};

export default Home;
```

## コンポーネント
### 検索フォームコンポーネント（components/SearchForm/index.tsx）
ポイント

- ``Props``でカスタムフックで定義したAPIフェッチ関数を受け取って、Submitイベント（検索ボタンクリック）でAPIを叩く
- フォーム入力有無でボタンカラーとボタンアクティブ・非アクティブを制御

```components/SearchForm/index.tsx
import React, { Dispatch, FormEvent, SetStateAction, useState } from "react";

interface Props {
  fetchArticles: (
    e: FormEvent<HTMLFormElement>,
    formText: string,
    setFormText: Dispatch<SetStateAction<string>>
  ) => void;
}

export const SearchForm: React.VFC<Props> = (props) => {
  const [formText, setFormText] = useState<string>("");
  const buttonColor = formText
    ? "bg-blue-700 hover:bg-blue-500" // フォーム入力有：ブルー
    : "bg-gray-300"; // フォーム入力無：グレー
  return (
    <form
      className="mt-12 mb-6"
      onSubmit={(e) => props.fetchArticles(e, formText, setFormText)}
    >
      <label className="block text-gray-700 text-lg font-bold mb-2">
        Qiita 記事 検索キーワードを入力
      </label>
      <input
        className="shadow appearance-none border rounded w-full py-2 px-3 mb-4 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
        placeholder="例：React"
        value={formText}
        onChange={(e) => setFormText(e.target.value)}
      />
      <button
        className={`${buttonColor} text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline`}
        type="submit"
        disabled={!formText}
      >
        検索
      </button>
    </form>
  );
};
```

### Qiita記事リストコンポーネント（components/Articlelist/index.tsx）
ポイント

- ``Props``以下を受け取り、４パターン（ローディング、エラー、検索結果なし、検索結果表示）でレンダリングする要素をハンドリング
  - ``articles：`` フェッチしたQiita記事リスト
  - ``searchWord：`` 検索ワード
  - ``errorMessage：`` エラーメッセージ
  - ``isLoading：`` ローディングフラグ
- Qiita記事は下位の``ArticleItem``コンポーネントに渡す

```components/Articlelist/index.tsx
import React from "react";
import { QiitaItem } from "../../types/QiitaItem";
import { ArticleItem } from "./ArticleItem";

interface Props {
  articles: Array<QiitaItem>;
  searchWord: string;
  errorMessage: string;
  isLoading: boolean;
}

export const ArticleList: React.VFC<Props> = (props) => {
  // ローディング中
  if (props.isLoading) {
    return (
      <p className="mb-2 p-8 bg-yellow-100 rounded-lg">ローディング.......</p>
    );
  }
  // エラー（API失敗）
  if (props.errorMessage) {
    return (
      <p className="mb-2 p-8 bg-red-100 rounded-lg">{props.errorMessage}</p>
    );
  }
  // 成功したものの、検索結果0件
  if (props.searchWord && props.articles?.length === 0) {
    return (
      <p className="mb-2 p-8 bg-green-100 rounded-lg">
        検索ワード
        <span className="font-bold border-b-2 border-black">
          {props.searchWord}
        </span>
        に該当なーーーし！！
      </p>
    );
  }

  return (
    <>
      {props.searchWord && (
        <p className="mb-4 text-xl">
          検索キーワード
          <span className="ml-2 font-bold text-blue-700 border-b-2 border-blue-700">
            {props.searchWord}
          </span>
        </p>
      )}
      {props.articles?.map((article) => {
        return <ArticleItem key={article.id} article={article} />;
      })}
    </>
  );
};
```
### Qiita記事コンポーネント（components/Articlelist/ArticleItem/index.tsx）

``Props``で受け取ったデータを表示するだけ

```components/Articlelist/ArticleItem/index.tsx
import React from "react";
import { QiitaItem } from "../../../types/QiitaItem";

interface Props {
  article: QiitaItem;
}

export const ArticleItem: React.VFC<Props> = ({ article }) => {
  return (
    <div className="mb-3 py-2 px-8 bg-blue-100 rounded-lg shadow">
      <p className="text-center font-bold  text-blue-800 mb-4 border-b-2 border-blue-800">
        {article.title}
      </p>
      <p className="text-blue-700">LGTM 👍：{article.likes_count}</p>
      <p className="text-blue-700">ユーザー：{article.user.name}</p>
    </div>
  );
};
```

# 最後に
今実務で入っている案件が``axios``を利用しているので今回は``axios``を使ってみました。
``SWR``とかを使えばエラーやローディングのハンドリングがもっと簡単になってきそうなので、``SWR``でもAPI叩く何か作ろうと思ってます。