KENTEM TechBlog

建設業のDXを実現するKENTEMの技術ブログです。

useMutation×useQueryで非同期処理と状態更新を最適化する方法

こんにちは、相も変わらずスポーツカーの動画を見ているフロントエンドエンジニアのY.Kです!

前回は、TanStack Queryの useQuery を使った状態管理についてご紹介しました。まだご覧になっていない方は、まずはこちらの記事をご一読ください。

さて今回は、「状態の更新」にフォーカスし、useMutation の活用方法を具体的なコード例とともにご紹介していきます。 useQuery と組み合わせることで、よりリアルなアプリケーション開発に役立つ内容になっていますので、ぜひ最後までご覧ください!

Reactだけでの状態更新

Reactで、APIを介したデータの登録・削除と状態の更新を行う際、以下のような実装を見かけたことがある方も多いのではないでしょうか?

type Data = { id: string, value: string }
const [data, setData] = useState<Data[]>()

const handleDataChange = (method: "post" | "delete", data: Data) => {
  try {
    await fetchApi[method](data)
    setData((prev) => {
      if (method === "post") return [...prev, data]
      return prev.filter((prevData) => prevData.id !== data.id)
    })
  } catch (error) {
    console.log("通信中にエラーが発生しました", error)
  }
}

const fetchApi = {
  post: async (data: Data): Promise<AxiosResponse<Data>> =>
    await axios.post<Data>(`api/post-endpoint`, data),
  delete: async (data: Data): Promise<AxiosResponse<Data>> =>
    await axios.delete<Data>(`api/delete-endpoint`)
}

このような処理でも、Reactの useState を使って状態の更新を行うことは十分に可能です。しかし、非同期処理の管理やUIの応答性、データベースとの整合性を考慮すると、useTransition・useStateの追加や、再取得処理の実装など、コードが複雑になってしまうこともあります。

そこで次に、TanStack QueryのuseMutationを使った、よりスマートで信頼性の高い状態の更新方法をご紹介します!

使い方

まずは、useMutation の基本的な使い方を見てみましょう。

type Data = { id: string, value: string }
const { mutate, status } = useMutation({
  mutationFn: async ({ data, method }: 
    { data: Data, method: "post" | "delete" }) => {
    // fetchApiは前述のものを使っています
    await fetchApi[method](data)
  }
})

const handler = (data: Data, method: "post" | "delete") => 
  mutate({ data, method })

このコードでは、useMutation の戻り値である mutate を使って、非同期処理を実行しています。handler 関数を呼び出すことで、指定した data と method に基づいて通信が行われます。

また、status には以下の4つの通信状態が格納されます

  • "idle":通信していない状態
  • "pending":通信中
  • "error":通信中にエラーが発生
  • "success":通信が成功

つまり、mutate で通信を行い、status を確認するだけで、非同期処理の通信状態管理が完結するのです。

次に、useQuery と組み合わせて、状態の更新まで行う方法を見てみましょう。

const queryClient = useQueryClient()
const { data } = useQuery({
  queryKey: ["data"],
  queryFn: async () => await fetch("/api/data/hoge").then(res => res.json())
})

const { mutate, status } = useMutation({
  mutationFn: async ({ data, method }: 
    { data: Data, method: "post" | "delete" }) => {
    // fetchApiは前述のものを使っています
    await fetchApi[method](data)
  },
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey:  ["data"] })
  },
  onError: () => {
    console.log("通信中にエラーが発生しました")
  },
  networkMode: "always"
})

const handler = (data: Data, method: "post" | "delete") => 
  mutate({ data, method })

このコードでは、useQueryとuseMutationは以下の図のような関係になっています。

useQuery によって取得したデータを data に保持し、mutate によって非同期処理を実行します。 この時、mutateFnが走り通信が成功すると onSuccess が呼ばれ、エラーが発生するとonErrorが呼ばれます。

またonSuccessの中でqueryClient.invalidateQueriseをすることで、useQueryを無効化し自動的にデータの再取得を行うことができます。 このinvalidateQueriesは引数に与えたqueryKeyを含むuseQueryを無効化するので注意が必要です。

これにより、非同期処理後の状態更新も自動で反映され、保守性と信頼性の高い構成が実現できます。

さらに、networkMode: "always" を設定することで、オフライン環境でも useMutation を起動できるようになります。

実装

ここまでで useMutation の基本的な使い方をご紹介してきました。

最後に、実践的な実装例を見ていきましょう。 今回も前回の記事と同様に、React と JSON Server を使ってモックAPI通信を行います。環境構築が必要となるため、詳細は前回の記事をご参照ください。

useQueryを使った非同期処理と状態管理のスマートな解決法 - KENTEM TechBlog

それでは実装は以下のようになります。

エントリーポイント(main.tsx)

import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

createRoot(document.getElementById('root')!).render(
  <QueryClientProvider client={new QueryClient()}>
    <App />
  </QueryClientProvider>
)

アプリ本体(App)

import { useState } from 'react'
import { CarList } from './components/car-list'
import { ManufactureSelector } from './components/manufacture-selector'
import { PostForm } from './components/post-form'
import { useCarQuery } from './hooks/use-car-query'
import { useCarMutation } from './hooks/use-car-mutation'

export type CarData = {
  id: string
  name: string
}

function App() {
  const [manufacture, setManufacture] = useState("Nissan")
  const { data } = useCarQuery(manufacture)
  const { mutate, status } = useCarMutation(manufacture)

  const handleChangeManufacture = (manufacture: string) => setManufacture(manufacture)
  const handleMutate = (data: { carId: string; carName: string; method: "post" | "delete" }) => mutate(data)

  return (
    <div className="App">
      <ManufactureSelector onChange={handleChangeManufacture} />
      <CarList data={data} onDelete={handleMutate} />
      <PostForm onMutate={handleMutate} status={status} />
    </div>
  )
}

export default App

hooks(useCarQuery、useCarMutation)

import { useQuery } from "@tanstack/react-query"
import axios, { AxiosError } from "axios"
import type { CarData } from "../App"

// 車の情報を取得・管理するためのhooks
export const useCarQuery = (manufacture: string) => {
  const query = useQuery<CarData[], AxiosError>({
    queryKey: ["carData", manufacture],
    queryFn: async () => {
      const response = await axios.get<CarData[]>(`http://localhost:3003/${manufacture}`)
      return response.data
    },
    networkMode: "always",
    enable: !manufacture
  })
  return query
}
import { useMutation, useQueryClient } from "@tanstack/react-query"
import type { AxiosError, AxiosResponse } from "axios";
import type { CarData } from "../App";
import axios from "axios";

type CarMutationParams = {
  carId: string;
  carName: string;
  method: "post" | "delete";
}

// 車の情報を送信・削除するためのhooks
export const useCarMutation = (manufacture: string) => {
  const queryClient = useQueryClient();

  const mutation = useMutation<
    CarData,
    AxiosError,
    CarMutationParams
  >({
    mutationFn: async ({ carId, carName, method }) => {
      const result = await fetchApi[method]({ id: carId, name: carName }, manufacture);
      return result.data;
    },
    onError: () => {
      console.error("エラーが発生しました");
    },
    onSuccess: () => {
      // 通信が成功したときは、useQueryを無効化
      queryClient.invalidateQueries({ queryKey: ["carData", manufacture] })
    },
    networkMode: "always",
  })
  return mutation
}

const fetchApi = {
  post: async (data: CarData, manufacture: string): Promise<AxiosResponse<CarData>> =>
    await axios.post<CarData>(`http://localhost:3003/${manufacture}`, data),
  delete: async (data: CarData, manufacture: string): Promise<AxiosResponse<CarData>> =>
    await axios.delete<CarData>(`http://localhost:3003/${manufacture}/${data.id}`)
}

コンポーネント(CarList、ManufactureSelector、PostForm)

import type { CarData } from "../App";

type Props = {
  data?: CarData[];
  onDelete: (data: { carId: string; carName: string; method: "delete" }) => void;
}

// 車の情報を表示するためのリストコンポーネント
export const CarList = ({ data, onDelete }: Props) => {
  return (
    <ul>
      {data?.map(car => (
        <li key={car.id} style={{ display: "flex", gap: 8, alignItems: "center" }}>
          <span>{`${car.name}(${car.id})`}</span>
          <button
            onClick={() => onDelete({ carId: car.id, carName: car.name, method: "delete" })}
          >
            削除
          </button>
        </li>
      ))}
    </ul>
  )
}
// 製造元を選択するためのセレクターコンポーネント
export const ManufactureSelector = ({ onChange }: { onChange: (manufacture: string) => void }) => {
  return (
    <select name="manufacture" onChange={e => onChange(e.target.value)}>
      <option value="Nissan">日産</option>
      <option value="Toyota">トヨタ</option>
      <option value="Honda">ホンダ</option>
    </select>
  )
}
import type { FormEvent } from "react";

// 車の情報を追加するフォームコンポーネント
export const PostForm = ({ onMutate, status }: 
        { onMutate: (data: { carId: string; carName: string; method: "post" }) => void;
          status: "idle" | "pending" | "error" | "success"; }) => {

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    const carId = formData.get("carId") as string
    const carName = formData.get("carName") as string
    e.currentTarget.reset()
    if (!carId || !carName) return
    onMutate({ carId, carName, method: "post" })
  }

  return (
    <>
      <form
        onSubmit={handleSubmit}
        style={{ marginTop: 20, display: "flex", gap: 8, alignItems: "center" }}
      >
        <input type="text" name="carId" placeholder="Car ID" />
        <input type="text" name="carName" placeholder="Car Name" />
        <button type="submit" disabled={status === "pending"}>
          {status === "pending" ? "送信中..." : "追加"}
        </button>
      </form>
      {status === "error" && (
        <div style={{ color: 'red' }}>
            エラーが発生しました
        </div>
      )}
    </>
  )
}

データベース

{
  "Nissan": [
    {
      "id": "S14",
      "name": "シルビア"
    }
  ],
  "Toyota": [
    {
      "id": "AE86",
      "name": "レビン"
    }
  ],
  "Honda": [
    {
      "id": "AP1",
      "name": "S2000"
    }
  ]
}

実際の表示画面

メーカー選択: ユーザーが ドロップダウンでメーカーを選ぶと、useCarQuery が呼ばれ、該当メーカーの車種データが取得されます。

車種一覧表示: 取得したデータは CarList コンポーネントで表示され、各車種に「削除」ボタンが付いています。

車種追加: PostForm コンポーネントで車種IDと名前を入力し、「追加」ボタンを押すと、useCarMutation を通じて post 通信が行われます。

車種削除: 「削除」ボタンを押すと、useCarMutation を通じて delete 通信が行われます。

通信成功時の処理: useMutation の onSuccess により、該当の queryKey を持つクエリが無効化され、最新データが自動で再取得されます。

通信状態の管理: status によって "pending", "error"の確認を行い、UI上で送信中やエラー表示を行っています。

まとめ

今回は、useMutationとuseQueryを使った非同期処理と状態更新の手法についてご紹介しました。 通信状態の管理や再取得の自動化など、より保守性の高い構成が実現できることを確認できたかと思います。

ただし、今回の実装では post や delete の通信の後に データの再取得(get) が走る構造になっており、パフォーマンスやUXの観点から、さらに改善の余地があると言えるでしょう。

また、今回少しだけ登場した queryClient は、TanStack Queryのキャッシュ操作や状態制御の中心的な存在ですが、その仕組みや使い方に疑問を持たれた方も多いのではないでしょうか?

次回はこの queryClient にフォーカスし、より高度な状態更新のテクニックをご紹介します!ぜひ次回もご覧ください!

参考文献

tanstack.com

おわりに

KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp