🏃‍♂️

痛みを原動力に、react queryでクラウチングスタート🔥

2025/01/25に公開

痛みを伴わない教訓には意義がない

痛みを伴わない教訓には意義がない
躾に一番効くのは痛みだと思う。お前に一番必要なのは言葉による「教育」ではなく「教訓」だ

これらのセリフは、それぞれ鋼の錬金術師と進撃の巨人のもので、私がとても好きなセリフです。

これらのセリフはエンジニアでも通づるものがあり、まさに苦労した経験から教訓として何か実装する術•ノウハウを身につけたことが誰しもあるのではないでしょうか。

今回はreact queryというライブラリに対して、これなしでの辛さを感じ、
その後にreact queryを使うモチベーションを追体験していただけたら嬉しいです。

react queryは、ただのデータフェッチライブラリじゃないを念押し

実際この記事で伝えたいことはこれに尽きます。
https://zenn.dev/hrbrain/articles/f5ef3016ed7a3a

react queryの一番のポイントはキャッシュ機構だと思っています。

軽いキャッシュ機能の紹介

React Queryは、APIのレスポンスデータをクエリキーと呼ばれるキーをもとに、キャッシュに保存します。
デフォルトはブラウザのメモリにキャッシュするようになっています。(再ロードすると消える)
オプションとして、localStorageなどにもキャッシュできます。

このキャッシュ機構により、以下のようなメリットがあります:

  • 同じデータを再利用
    同じクエリキーを使用する別のコンポーネントでも、キャッシュされたデータを即座に利用可能です。APIを何度も呼び出す必要がなく、効率的です。

  • 任意のタイミングで、キャッシュ破棄できる
    todoリストのデータをfetchして、キャッシュした後にtodoリストを追加した場合なども、追加したタイミングで、todoリストのデータキャッシュを破棄することで、最新のtodoリストデータを取得するといったことが可能です。

  • 自動再フェッチを制御できる
    オプションでキャッシュを保持する期間を決められます。期間を超えると自動的に再フェッチされます。

  • データの一貫性
    アプリ全体で同じクエリキーを使用することで、データが最新の状態に保たれます。

react queryを使っていない時にこういう経験はないだろうか

運用が辛くなってくるコード例のイメージ↓↓↓

import create from "zustand";

const useApiStore = create((set) => ({
  data: null, // APIのデータ
  setData: (data) => set({ data }), // データを更新する関数
}));


import React, { useState, useEffect } from "react";

const MyComponent = () => {
  const {data, setData} = useApiStore();
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true); // ローディング開始
      try {
        const response = await fetch("/api/data");
        if (!response.ok) {
          throw new Error("Network response was not ok");
        }
        const result = await response.json();
        setData(result); // データをセット
      } catch (err) {
        setError(err); // エラーをセット
      } finally {
        setIsLoading(false); // ローディング終了
      }
    };

    fetchData();
  }, []);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return <div>{JSON.stringify(data)}</div>;
};

export default MyComponent;


1. ローディングロジック、エラーロジックを自作した
2. useEffectで、apiを呼んでいる
3. 他のcomponentにapiの結果を共有したいため、storeに保存した。

基本的に上記の作業は全てプロジェクトのドメインが複雑だったり、規模が大きくなると管理コストがめちゃくちゃ上がります。
apiを呼ぶタイミングが異なれば、その分useEffectが乱立したり、
apiが増えた分だけ、ローディングロジック等が追加されたり、
apiの結果をstoreで保存するとなると、例えばマスタデータをbackendから取得している設計の場合はマスタデータごとにstoreを保持しなければならないという設計になってしまいます。

こういった状態になってしまうと管理が辛くなってきて、ちゃんと痛みを感じることができます。
この状態になったら確実にreact queryの恩恵を魂で感じることができます!

react queryでこういうコードは抹消できる

次に同じcomponentでreact queryを使用した場合のコードを見ていきましょう。


import React from "react";
import { useQuery } from "react-query";

// データフェッチ関数
const fetchData = async () => {
  const response = await fetch("/api/data");
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }
  return response.json();
};

const MyComponent = () => {
  // useQueryフックを使用してデータ取得。 "apiData"がクエリキー。
  const { data, error, isLoading } = useQuery("apiData", fetchData);

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return <div>{JSON.stringify(data)}</div>;
};

export default MyComponent;

...なんてことでしょう。
useEffectが消えた!、カスタムロジックも消滅した!
storeも消えた!なんてことだ!オーマイガー!最高!

あの...
...ん?

新たにreact queryのためにデータフェッチ関数が追加されているのですが、
これはどう管理したら良いですか?

ファイルの分け方はこれが参考になる。

https://zenn.dev/hrbrain/articles/1202f4d107d890#3.-querykeyを一意に保つ

また、私の参画しているプロジェクトでは、orvalというswaggerからreact queryを用いたapi clientを生成するライブラリを用いているため、それでデータフェッチ関数やkeyは管理しています。

https://orval.dev/guides/react-query

イメージとしては、以下のようにorval.config.tsを記述して、orvalの生成コマンドを実行すると
/src/orval/generated/modelsにschemeの型ファイルが生成され、
/src/orval/generated/repository/index.tsファイルに全てのapi clientがまとめて出力されるイメージです。

import { defineConfig } from "orval";

export default defineConfig({
  webClient : {
        input: {
        target: "http://localhost:8088/swagger/v1/swagger.json",
        },
        output: {
            mode: "split",
            schemas: "./src/orval/generated/models",
            client: "react-query",
            mock: false,
            biome: true,
            target: "./src/orval/generated/repository/index.ts",
            override: {
                mutator: {
                    path: "./src/api/axios/customInstance.ts",
                    name: "customInstance",
                }
            },
        },
  },
});

生成されるreact queryのコードのイメージ↓↓↓

 export const showPetById = (
   petId: string,
   options?: AxiosRequestConfig,
 ): Promise<AxiosResponse<Pet>> => {
   return axios.get(`/pets/${petId}`, options);
 };
 
 export const getShowPetByIdQueryKey = (petId: string) => [`/pets/${petId}`];
 
 export const useShowPetById = <
   TData = AsyncReturnType<typeof showPetById>,
   TError = Error,
 >(
   petId: string,
   options?: {
     query?: UseQueryOptions<AsyncReturnType<typeof showPetById>, TError, TData>;
     axios?: AxiosRequestConfig;
   },
 ) => {
   const { query: queryOptions, axios: axiosOptions } = options ?? {};
 
   const queryKey = queryOptions?.queryKey ?? getShowPetByIdQueryKey(petId);
   const queryFn = () => showPetById(petId, axiosOptions);
 
   const query = useQuery<AsyncReturnType<typeof queryFn>, TError, TData>(
     queryKey,
     queryFn,
     { enabled: !!petId, ...queryOptions },
   );
 
   return {
     queryKey,
     ...query,
   };
 };

orvalで生成できれば管理コストがまた減らせるので、使えそうな現場であれば積極的に検討してみるのがおすすめです!(ただし、api clientがaxiosベースになってしまった気がするのでそこは注意してください)

あらためてメリットを整理

react queryを採用したことで、以下のメリットがありました。

あるcomponentの中では...

  • useEffectの数を減らせた
  • ローディング状態やエラー状態のカスタムロジックを減らせた
  • storeも減らすことができた。
  • 無駄な通信を減らすことができた。

component間の関係では...

  • propsとして、apiの結果を渡す必要も無くなったため、componentは別のcomponentを気にすることなく、脳死で同じapiを呼ぶことが可能になった。
  • featureディレクトリの設計との相性が良く、開発者はそれぞれ分担して作業をできる。依存関係が減った。

開発者とプロジェクトの関係としては...

  • コードが簡素化され、ある一定のルールで記述できるためキャッチアップコスト、運用コストも削減できた。

ここまできたらあとはreact queryのテクニックを磨いてどんどん実装に落としこんでいきましょう!

こういう実装パターンができる

  • あるある1
    あるapiの結果を取得でき次第、他のapiの結果を取得したい。
    useQueryのenableオプションを記述することで対応できます。
  const { data: user, isLoading: isUserLoading } = useQuery("user", fetchUser);

  const { data: orders, isLoading: isOrdersLoading } = useQuery(
    ["orders", user?.id],
    () => fetchOrders(user.id),
    {
      enabled: !!user, // ユーザーデータが存在する場合のみクエリを実行
    }
  );
  • あるある2
    ページネーションでページが変わったらそのページのデータを取得したい
const PaginationExample = () => {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPreviousData } = useQuery(
    ["items", page],
    () => fetchPageData(page),
    {
      keepPreviousData: true, // 前のデータを保持
    }
  );

  if (isLoading) return <p>Loading...</p>;

  return (
    <div>
      <h1>Items on Page {page}</h1>
      <ul>
        {data.items.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
      <button
        onClick={() => setPage((old) => Math.max(old - 1, 1))}
        disabled={page === 1}
      >
        Previous
      </button>
      <button
        onClick={() => setPage((old) => old + 1)}
        disabled={isPreviousData || !data.hasMore}
      >
        Next
      </button>
    </div>
  );
};

以下のコードの第一引数であるクエリキーが動的になっているため、ここが変わると自動的にfetchが走りデータが更新されます。

  const { data, isLoading, isPreviousData } = useQuery(
    ["items", page],
    () => fetchPageData(page),
    {
      keepPreviousData: true, // 前のデータを保持
    }
  );

おまけ、orvalとの連携した時のエラーハンドリング

エラーハンドリングは、bulletproof-reactの以下のapi clientが参考になりました。
api-client.ts
https://github.com/alan2207/bulletproof-react/blob/master/apps/react-vite/src/lib/api-client.ts

provider.tsx
https://github.com/alan2207/bulletproof-react/blob/master/apps/react-vite/src/app/provider.tsx

orvalでもaxiosのapi clientをカスタムできるので同じ実装が実現できます。
https://orval.dev/guides/custom-axios

おすすめの実装なので、ぜひ試してみてください!

最後に

今回の記事でreact queryを使うまでの痛みと使うモチベーションを感じてもらえたら嬉しいです。
react queryには他にもシンプルでパワフルな機能が色々あるので、詳しい箇所はドキュメントを見ていただけたらと思います!
また、日々様々な技術が登場してきますが、その技術が生まれるまでの痛みを知ってさえいれば
変な使い方をせずに強力な武器として使用できると思います。私もそういうところには繊細になって技術を追っていきたいと心に刻みました。
皆さんもhappyなreact queryライフを送ってください。ご視聴ありがとうございました!

Discussion