React QueryでPokeAPIを叩いてみた

## この記事で分かること
- React Queryでのデータ取得方法
- React Queryを使用した場合と使用しない場合の違い

## React Queryとは
- TanStack QueryのReactアダプター
    - TanStack QueryとReactフレームワークの間でデータの取り扱いや通信を調整する
- データの取得、キャッシュ、更新の仕組みを提供する
    - サーバーステートの管理ライブラリであると説明されている

## React Queryが提供する機能
- ローディング
- エラー
- ページング
- 無限スクロール
- プリフェッチ
- データの更新
- 重複排除（De-duplication）
- リトライ
- コールバック


## 利用手順
- パッケージのインストール
    - package.jsonの有るディレクトリで以下を実行
        - `npm install @tanstack/react-query`
- 親コンポーネント（今回はindex.js）でクエリクライアントの作成
    - クエリクライアントはクエリとキャッシュを管理する
- 親コンポーネント（今回はindex.js）でクエリプロバイダを設定
    - Propsとしてクエリクライアント渡す
- 子コンポーネント（今回はApp.js）で`useQuery`を呼び出す
- `userQuery`の引数としてqueryKeyとqueryFnが必須
- `queryKey`はクエリの実行結果を一意に特定し、キャッシュに利用される（queryKeyが同じならAPIを叩かずにuseQueryのキャッシュデータが利用される）
- `queryFn`は実際にAPIを叩く関数を指定する

## サンプルコード

### 全ソースコードはこちら

https://github.com/takayukiblue/react-pokemon-app

### 機能概要
- PokeAPIにアクセスし、ポケモン情報を取得する
- 1ページあたり20体のポケモンを表示する
- Prevボタンで前の20体を表示する
- Nextボタンで次の20体を表示する

### 実行結果

![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/386528/9166d193-bd6a-6a8e-c8fb-cce93afb5b26.png)


### フォルダ構成（抜粋）
```
ProjectRoot
  └─ src
       ├─ components
       │      └─ Card.js
       ├─ utils
       │      └─ pokemon.js
       ├─ App.js
       └─ index.js

```

### src/index.js
``` React
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>
);
```

### src/App.js
``` React
import { useState } from "react";
import "./App.css";
import { getAllPokemon, getPokemon } from "./utils/pokemon";
import Card from "./components/Card";
import { useQuery } from "@tanstack/react-query";

function App() {
  const initialURL = "https://pokeapi.co/api/v2/pokemon/";
  const [offset, setOffset] = useState(0);

  // 前ページ
  const handlePrevPage = () => {
    if (offset === 0) {
      return;
    }
    setOffset((prev) => prev - 20);
  };

  // 次ページ
  const handleNextPage = () => {
    setOffset((prev) => prev + 20);
  };

  // ポケモン一覧を取得するuseQuery
  const { data: pokemonList, isLoading: isLoading1 } = useQuery({
    queryKey: ["pokemonList", offset],
    queryFn: () => getAllPokemon(initialURL, offset),
  });

  // ポケモンの詳細データ取得ラッパー
  const loadPokemon = async (data) => {
    let _pokemonData = await Promise.all(
      data.map((pokemon) => {
        let pokemonRecord = getPokemon(pokemon.url);
        return pokemonRecord;
      })
    );
    return _pokemonData;
  };

  // ポケモンの詳細データを取得するuseQuery
  const { data: pokemonData, isLoading: isLoading2 } = useQuery({
    queryKey: ["pokemonData", pokemonList],
    queryFn: () => loadPokemon(pokemonList.results),
    enabled: !!pokemonList, // ポケモン一覧取得が完了している場合に動作させる
  });

  // ポケモンデータの表示
  return (
    <div className="App">
      {isLoading1 || isLoading2 ? (
        <h1>Loading...</h1>
      ) : (
        <>
          {/* 各ポケモンの表示 */}
          <div className="pokemonCardContainer">
            {pokemonData.map((pokemon, i) => {
              return <Card key={i} pokemon={pokemon} />;
            })}
          </div>

          {/* PrevボタンとNextボタン */}
          <div style={{ margin: "50px" }}>
            <button onClick={handlePrevPage}>Prev</button>
            <button onClick={handleNextPage}>Next</button>
          </div>
        </>
      )}
    </div>
  );
}

export default App;
```

### src/components/Card.js
``` React
import React from "react";

const Card = ({ pokemon }) => {
  return (
    <div className="card" style={{ marginTop: "50px" }}>
      <div className="cardImg">
        <img src={pokemon.sprites.front_default} alt="" />
      </div>
      name: {pokemon.name}
      <br />
      weight: {pokemon.weight}
    </div>
  );
};

export default Card;
```

### src/utils/pokemon.js
``` React
// ポケモン一覧の取得
export const getAllPokemon = async (url, offset = 0) => {
  try {
    const response = await fetch(`${url}?offset=${offset}&limit=20`);
    const data = await response.json();
    return data;
  } catch (error) {
    throw error;
  }
};

// ポケモンの詳細データ取得
export const getPokemon = async (url) => {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  } catch (error) {
    throw error;
  }
};
```

## React Queryを使わない場合
### src/index.js
``` React
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
```

### src/App.js
``` React
import { useEffect, useState } from "react";
import "./App.css";
import { getAllPokemon, getPokemon } from "./utils/pokemon";
import Card from "./components/Card/Card";

function App() {
  const initialURL = "https://pokeapi.co/api/v2/pokemon/";
  const [loading, setLoading] = useState(true);
  const [pokemonData, setPokemonData] = useState([]);
  const [prevUrl, setPrevUrl] = useState("");
  const [nextUrl, setNextUrl] = useState("");

  useEffect(() => {
    const fetchPokemonData = async () => {
      // ポケモン一覧取得
      let res = await getAllPokemon(initialURL);
      // 詳細データ取得
      loadPokemon(res.results);
      // 前ページと次ページのURLをセット
      setPrevUrl(res.previous);
      setNextUrl(res.next);
      // ローディング終了
      setLoading(false);
    };
    fetchPokemonData();
  }, []);

  // ポケモンの詳細データ取得ラッパー
  const loadPokemon = async (data) => {
    let _pokemonData = await Promise.all(
      data.map((pokemon) => {
        let pokemonRecord = getPokemon(pokemon.url);
        return pokemonRecord;
      })
    );
    setPokemonData(_pokemonData);
  };

  // 前ページ
  const handlePrevPage = async () => {
    if (!prevUrl) return;

    setLoading(true);
    let data = await getAllPokemon(prevUrl);
    await loadPokemon(data.results);
    setPrevUrl(data.previous);
    setNextUrl(data.next);
    setLoading(false);
  };

  // 次ページ
  const handleNextPage = async () => {
    setLoading(true);
    let data = await getAllPokemon(nextUrl);
    await loadPokemon(data.results);
    setPrevUrl(data.previous);
    setNextUrl(data.next);
    setLoading(false);
  };

  // ポケモンデータの表示
  return (
    <div className="App">
      {loading ? (
        <h1>Loading...</h1>
      ) : (
        <>
          {/* 各ポケモンの表示 */}
          <div className="pokemonCardContainer">
            {pokemonData.map((pokemon, i) => {
              return <Card key={i} pokemon={pokemon} />;
            })}
          </div>

          {/* PrevボタンとNextボタン */}
          <div style={{ margin: "50px" }}>
            <button onClick={handlePrevPage}>Prev</button>
            <button onClick={handleNextPage}>Next</button>
          </div>
        </>
      )}
    </div>
  );
}

export default App;
```

### src/components/Card.js
※React Queryを使う場合とコードの内容は同じ
``` React
import React from "react";

const Card = ({ pokemon }) => {
  return (
    <div className="card" style={{ marginTop: "50px" }}>
      <div className="cardImg">
        <img src={pokemon.sprites.front_default} alt="" />
      </div>
      name: {pokemon.name}
      <br />
      weight: {pokemon.weight}
    </div>
  );
};

export default Card;
```

### src/utils/pokemon.js
``` React
// ポケモン一覧の取得
export const getAllPokemon = async (url) => {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  } catch (error) {
    throw error;
  }
};

// ポケモンの詳細データ取得
export const getPokemon = async (url) => {
  try {
    const response = await fetch(url);
    const data = await response.json();
    return data;
  } catch (error) {
    throw error;
  }
};
```

## 利点
- React Queryを使用した場合はキャッシュが機能し、1度表示したデータでは表示が速い
- ページングの処理が簡単
- ローディングやエラーの記載方法がシンプル

## 補足
### React Query Dev Tools
- 主な機能
    - クエリの確認
    - データの確認
- インストール
    - `npm install @tanstack/react-query-devtools`
- 利用方法
    - `ReactQueryDevtools`をインポートする
    - クエリプロバイダの子コンポーネントとして配置する
- コンポーネントの配置例

``` React
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

const queryClient = new QueryClient();

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools />
    </QueryClientProvider>
  </React.StrictMode>
);
```

- ツールが組み込まれるとブラウザ上に以下のアイコンが表示される

![react_query_dev_tool_icon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/386528/db7a2a88-e4c7-dfa7-da20-7166c6e8cd30.png)

- ツールでのクエリ実行結果表示サンプル

![react_query_dev_tool_result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/386528/24130b97-df9f-45e8-9a1d-31d9e59ece7a.png)


### isFetchingとisLoadingの違い
- isFetching
    - 非同期処理のクエリが未完了の場合にtrue
- isLoading
    - 非同期処理のクエリが未完了か、キャッシュデータがない場合にtrue

### ステートとは
- ステートの例
    - サーバーから取得したデータ
    - UIの設定値（ライトモード・ダークモードなど）
    - エラーリスト
    - 等
- ステートに該当しない
    - Props
    - 固定値
    - 他のステートやPropsから算出されるもの

### ステートの種類
- グローバルステート
- ローカルステート
- サーバーステート
- クライアントステート

## 参考
- [TanStack Query](https://tanstack.com/query/latest)
- [PokeAPI](https://pokeapi.co/)
- [【Reactアプリ開発】3種類のReactアプリケーションを構築して、Reactの理解をさらに深めるステップアップ講座 - Udemy](https://www.udemy.com/course/react-3project-app-udemy/?utm_source=adwords&utm_medium=udemyads&utm_campaign=LongTail_la.JA_cc.JP&utm_content=deal4584&utm_term=_._ag_107181210924_._ad_452531407125_._kw__._de_c_._dm__._pl__._ti_dsa-930814700879_._li_9053276_._pd__._&matchtype=&gad_source=1&gclid=CjwKCAiA1-6sBhAoEiwArqlGPrd4I8Gi9Xxgf5WJKw3iIIiNsC5KINpfCHfMK_3qaZ_-7y-au0awghoCt-MQAvD_BwE)
- [React Query/TanStack Query: React Server State Management - Udemy](https://www.udemy.com/course/learn-react-query/)
- [State Management with React Query - Kindle](https://amzn.asia/d/d8Vindl)