4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

useEffectはやめて、TanStack Queryを使おう

4
Posted at

useEffect + useState でデータ取得、書けるけどつらい。
キャッシュもない、ローディング管理も毎回手書き、同じコードのコピペ地獄。

TanStack Query を使えば、それ全部 3 行で終わります。この記事では「なぜ useEffect がつらいのか」から始めて、TanStack Query の基本から楽観的更新まで一気に解説します。

TanStack Query(React Query)v5 とは

TanStack Query(旧 React Query)は、React アプリケーションにおけるサーバーステート管理を劇的にシンプルにするライブラリです。

「サーバーステート」とは、API から取得するデータのことです。たとえば、ユーザー一覧や投稿データなど、サーバー側に本体があり、フロントエンドではそのコピーを表示しているデータを指します。こうしたデータは「いつ古くなるか」「誰かが裏で更新していないか」といった問題があり、フロントエンドだけの状態(モーダルの開閉やフォームの入力値など)とは性質がまったく異なります。

TanStack Query を使うと、データの取得・キャッシュ・再取得・エラー処理といった面倒な処理を、ほんの数行のコードで実現できます。

v5 は 2023年10月にリリースされたメジャーバージョンで、v4 から多くの改善が行われました。主な特徴は以下のとおりです:

  • API の簡素化 — オーバーロードの廃止、オプション名の整理(cacheTimegcTime など)により、一貫性のある API に
  • Suspense の正式サポートuseSuspenseQuery が first-class API として追加され、React Suspense との統合が安定化
  • TypeScript 体験の向上 — エラー型のデフォルトが Error に、queryOptions による型安全なクエリ定義の共有が可能に
  • バンドルサイズの削減 — 約20%の軽量化

インストール方法

npm install @tanstack/react-query @tanstack/react-query-devtools

v5 では React 18 以上が必須です(useSyncExternalStore を使用するため)。

なぜ TanStack Query が必要なのか

従来の React アプリでは、API からデータを取得するために useEffectuseState を組み合わせて、ローディング状態・エラー状態・データ本体をそれぞれ自分で管理する必要がありました。

// 従来の方法:自分で全部管理しなければならない
const [data, setData] = useState<Todo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
  fetch("/api/todos")
    .then((res) => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setIsLoading(false));
}, []);

この書き方には以下の問題があります:

  • 毎回同じようなコードを書く必要がある(ボイラープレートが多い)
  • キャッシュの仕組みがない — 同じデータを複数のコンポーネントで使うと、それぞれが個別に API を呼んでしまう
  • データの鮮度管理ができない — 一度取得したデータがいつまでも古いまま表示される
  • 画面を離れて戻ったときの再取得など、細かい制御を自分で実装する必要がある

TanStack Query を使えば、これらの問題がすべて解消されます。

// TanStack Query:キャッシュ・再取得・エラー処理を自動で管理してくれる
const { data, isPending, error } = useQuery({
  queryKey: ["todos"],
  queryFn: () => fetch("/api/todos").then((res) => res.json()),
});

たったこれだけで、ローディング状態の管理、エラーハンドリング、キャッシュ、バックグラウンドでの再取得が全て自動で行われます。

Redux や Context で API データを管理しているプロジェクトも多いですが、サーバーから取得するデータは TanStack Query のような専用ツールに任せ、Redux/Context はフロントエンド固有の状態(UI の状態など)だけに使うのがおすすめです。

セットアップ

TanStack Query を使うには、アプリのルート(一番外側)に QueryClientProvider というコンポーネントを配置します。これは「アプリ全体で TanStack Query を使えるようにする設定」だと思ってください。

// workspace/App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";

// QueryClient はコンポーネントの「外側」で作成する(再レンダリングのたびに作り直されないようにするため)
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,     // 1分間はキャッシュを「新鮮」とみなす(この間は再取得しない)
      gcTime: 1000 * 60 * 5,    // 5分間キャッシュをメモリに保持する
      retry: 2,                 // API 呼び出しが失敗したら2回まで自動リトライ
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* アプリ本体 */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

ReactQueryDevtools は開発時にキャッシュの状態をブラウザ上で確認できるツールです。本番ビルドでは自動的に除外されるので、入れておいて損はありません。

デフォルト設定の補足

上記の設定項目について、もう少し詳しく説明します。

オプション デフォルト値 説明
staleTime 0(即座に古いと判断) データが「古い(stale)」と見なされるまでの時間。古くなったデータは、次にそのデータが必要になったときに自動で再取得される
gcTime 5分 使われなくなったキャッシュがメモリから削除されるまでの時間(v4 では cacheTime という名前だった)
retry 3 API 呼び出しが失敗したときのリトライ回数。間隔は自動的に長くなる(指数バックオフ)
refetchOnWindowFocus true 別のタブから戻ってきたときに、古いデータを自動で再取得する
refetchOnReconnect true ネットワークが切れて復帰したときに、自動で再取得する

staleTimegcTime の違いがわかりにくいかもしれません。簡単に言うと:

  • staleTime = 「このデータはまだ新しいから、再取得しなくていいよ」という期間
  • gcTime = 「このデータはもう画面で使われてないけど、メモリには残しておくよ」という期間

useQuery によるデータ取得

useQuery は TanStack Query の最も基本的なフックです。API からデータを取得するときに使います。

const { data, isPending, isError, error } = useQuery({
  queryKey: ["todos"],       // このデータを識別するためのキー(キャッシュのキーになる)
  queryFn: fetchTodos,       // データを取得する関数
});

if (isPending) return <p>読み込み中...</p>;
if (isError) return <p>エラー: {error.message}</p>;

return <ul>{data.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>;

queryKey はキャッシュを識別するためのキーです。同じ queryKey を持つ useQuery が複数のコンポーネントにあっても、API の呼び出しは1回だけ行われ、結果が共有されます。これがキャッシュの仕組みです。

queryFn は実際にデータを取得する関数です。fetchaxios など、Promise を返す関数を指定します。

v5 での変更点(v4 経験者向け)

  • ステータスが loadingpending に変更。旧 isLoading の役割は isPending が引き継ぎ、新しい isLoadingisPending && isFetching(初回読み込み中のみ true)として再定義
  • オーバーロード廃止(常にオブジェクト形式で書く)
  • エラーのデフォルト型が Error に(v4 では unknown

queryOptions でクエリ定義を共有する

同じデータ取得処理を複数の場所で使いたい場合、queryOptions を使ってクエリの定義を1箇所にまとめることができます。v5 で追加された機能です。

// workspace/hooks/useTodos.ts
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { fetchTodos } from "../api/todos";

// クエリの定義を1箇所にまとめる
export const todosQueryOptions = queryOptions({
  queryKey: ["todos"],
  queryFn: fetchTodos,
});

// この定義を useQuery でも prefetchQuery でも使い回せる
export const useTodos = () => useSuspenseQuery(todosQueryOptions);

こうしておけば、queryKey のタイポや queryFn の書き間違いを防げますし、TypeScript の型推論も正しく効きます。

useSuspenseQuery で Suspense 対応

React には Suspense という仕組みがあり、「データ読み込み中の表示」をコンポーネントの外側で宣言的に書けます。TanStack Query v5 では、この Suspense に対応した useSuspenseQuery フックが正式に用意されました。

まず、親コンポーネントで「読み込み中」と「エラー時」の表示を定義します。

import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";

// 親コンポーネント:ローディングとエラーの表示を担当
<ErrorBoundary fallback={<p>エラーが発生しました</p>}>
  <Suspense fallback={<p>読み込み中...</p>}>
    <TodoList />
  </Suspense>
</ErrorBoundary>

子コンポーネントでは、ローディングやエラーのことを気にせず、データがある前提のコードだけを書けます。

// workspace/components/TodoList.tsx
import { useTodos } from "../hooks/useTodos";

export const TodoList = () => {
  // data は常に存在する(undefined にならない!)
  const { data: todos } = useTodos();

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};

通常の useQuery では dataundefined かもしれないので毎回チェックが必要ですが、useSuspenseQuery では data が必ず存在することが型レベルで保証されます。これにより、コンポーネントのコードがすっきりします。

useSuspenseQuery のポイント:

  • data が常に T 型(T | undefined ではない)
  • ローディング・エラー状態のハンドリングが不要(親の Suspense/ErrorBoundary に任せる)
  • enabled オプションは使用不可(Suspense の仕組みと矛盾するため)

useMutation でデータの更新

ここまではデータの「取得(読み取り)」でしたが、データの「作成・更新・削除」には useMutation を使います。

// workspace/hooks/useTodos.ts より抜粋
export const useCreateTodo = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateTodoInput) => createTodo(input),
    onSuccess: () => {
      // 作成が成功したら、Todo一覧のキャッシュを無効化して再取得させる
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
};

invalidateQueries は「このキャッシュはもう古いので、次に必要になったら再取得してね」と TanStack Query に伝える関数です。画面に表示中であれば即座に再取得が走ります。

コンポーネント側では以下のように使います。

// workspace/components/AddTodo.tsx
const createMutation = useCreateTodo();

const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault();
  createMutation.mutate({ title: "新しいTodo" });
};

// isPending を使ってボタンを無効化し、二重送信を防止
<button disabled={createMutation.isPending}>
  {createMutation.isPending ? "追加中..." : "追加"}
</button>

楽観的更新(Optimistic Updates)

通常、データを更新すると「送信 → サーバーで処理 → 成功を受け取る → 画面を更新」という流れになります。しかしこれだと、サーバーの応答を待つ間、ユーザーは画面が変わらず待たされます。

楽観的更新とは、「サーバーの応答を待たずに、成功するだろうと見込んで先に画面を更新してしまう」テクニックです。もしサーバー側で失敗した場合は、元の状態にロールバック(巻き戻し)します。SNS の「いいね」ボタンなどでよく使われるパターンです。

v5 では2つのアプローチが用意されています。

方法1: キャッシュを直接操作する(複数箇所で反映が必要な場合)

この方法は、同じデータを表示している複数のコンポーネントすべてに即座に反映したい場合に使います。

// workspace/hooks/useTodos.ts より
export const useCreateTodo = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (input: CreateTodoInput) => createTodo(input),
    onMutate: async (newTodo) => {
      // 1. 進行中の再取得をキャンセル(楽観的に書き換えたデータが上書きされるのを防ぐ)
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      // 2. 現在のキャッシュを保存しておく(失敗時にロールバックするため)
      const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);

      // 3. キャッシュを楽観的に更新(まだサーバーには反映されていない)
      queryClient.setQueryData<Todo[]>(["todos"], (old) => [
        { id: Date.now(), title: newTodo.title, completed: false },
        ...(old ?? []),
      ]);

      // 4. ロールバック用のデータを返す
      return { previousTodos };
    },
    onError: (_err, _newTodo, context) => {
      // サーバー側で失敗した場合、保存しておいたデータに戻す
      if (context?.previousTodos) {
        queryClient.setQueryData(["todos"], context.previousTodos);
      }
    },
    onSettled: () => {
      // 成功・失敗に関わらず、サーバーから最新データを再取得して確実に同期する
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
};

方法2: variables を使って UI を直接更新する(シンプルなケース)

1箇所だけ表示を変えたい場合は、もっとシンプルに書けます。

const mutation = useMutation({ mutationFn: updateTodo });

// mutation.variables で「いま送信中のデータ」にアクセスできる
<span>
  {mutation.isPending
    ? mutation.variables.title  // 送信中は、送ったデータを先に表示
    : todo.title}               // それ以外はサーバーのデータを表示
</span>

方法2はコードが少なくロールバック処理も不要ですが、そのコンポーネント内でしか反映されません。複数コンポーネントに反映が必要な場合は方法1を使いましょう。

v4 からの主な破壊的変更まとめ

すでに v4 を使っている方向けに、v5 での主な変更点をまとめます。

v4 v5 理由
cacheTime gcTime ガベージコレクション(不要データの自動削除)の意図を正確に表現
keepPreviousData placeholderData に統合 API の簡素化
isLoading isPending(旧 isLoading の役割)。新 isLoading = isPending && isFetching ステータス loadingpending に変更。isLoading は初回読み込み中のみ true に再定義
useErrorBoundary throwOnError 機能を正確に反映
オーバーロード対応 オブジェクト形式のみ API の一貫性
onSuccess / onError (useQuery) 削除 詳細は公式ブログ参照

v4 からの移行には公式のコードモッド(自動変換ツール)が用意されています。オーバーロードの除去を自動で行えます。

# TypeScript/TSX ファイルの場合
npx jscodeshift@latest ./path/to/src/ \
  --extensions=ts,tsx \
  --parser=tsx \
  --transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.cjs

コードモッドは全てのケースをカバーできるわけではないので、適用後はコードを必ずレビューしてください。

まとめ

TanStack Query v5 は、React アプリにおけるサーバーデータ管理の定番ライブラリです。

  • useQuery: API データの取得・キャッシュ・自動再取得をまとめて管理
  • useSuspenseQuery: React Suspense と組み合わせて、ローディング処理をコンポーネントの外に分離
  • useMutation: データの作成・更新・削除と、楽観的更新のサポート
  • queryOptions: クエリ定義を1箇所にまとめて、型安全に使い回す

useEffect + useState でのデータ取得がつらい」と感じたら、TanStack Query の導入を検討してみてください。

参考リンク

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?