本文書ではTanStack Query(旧React Query)を使ってシンプルな Todoアプリを作成することでTanStack Queryの初歩的な使い方を確認していきます。

React Queryについては別記事ですでに公開していますが一つの前のバージョンのuseQueryに特化しておりuseMutationについては説明は行っていません。

TanStack Queryではデータを取得するuseQuery Hookだけではなくデータの作成/更新/削除を行うことができるuseMutation Hookもあります。useMutation Hookの動作確認をするためにバックエンドサーバを構築してデータベースを利用します。バックエンドサーバにはExpress, データベースにはSQLite、サーバとデータベースの仲介にPrismaを利用します。TanStack Queryだけではなくバックエンドサーバの環境方法についても一緒に学ぶことができます。

本文書はTanstack Queryのバージョン4を元に作成しています。現在の最新バージョンはバージョン5です。バージョン5ではuseQueryの引数の指定方法など変更が行われています。
fukidashi

TanStack Queryとは

TanStack Queryはバージョン3まではReact Queryと呼ばれていましたがバージョン4になり名称がTanStack Queryになりました。以前までのバージョンで利用されてきたReact Queryは名前にReactが含まれている通りReact専用のライブラリでした。バージョンアップしてTanStack QueryになりReactだけではなくSolid, Vue, Svelteなどでも利用できるようになりました。

Tanstack Queryを簡単に説明するとサーバの状態(データ)をfetching, caching, synchronizing and updating(サーバ上のデータをフェッチ、キャッシュ、同期、更新)することができるdata fetchingライブラリです。言葉の説明では分かりにくいかもしれませんがTodoアプリを作成しながらTanstack Query理解を深めていきます。

バックエンドサーバの設定

バックエンドサーバにはExpress、データベースにはSQLiteを利用します。ExpressからSQLiteを利用するためにPrismaを利用しています。データの作成、更新、削除に利用できるバックエンドの環境が準備できている場合はこの章をスキップしてください。

Expressサーバの設定

Expressサーバをインストールするためにbackendフォルダを作成します。backendフォルダを作成後、backendフォルダに移動してnpm init -yコマンドを実行してpackage.jsonファイルの作成を行います。


 % mkdir backend
 % cd backend
 % npm init -y

expressとnodemonのパッケージのインストールを行います。nodemonをインストールすることでexpressサーバの処理を記述するファイルの更新を監視し更新が行われると自動で再読み込みを行ってくれます。


 % npm install express nodemon

index.jsファイルを作成します。index.jsファイルにexpressの処理を記述していくためpackage.jsonファイルを開いてscriptsの箇所にnodemonの設定を行います。npm startコマンドを実行すると”nodemon index.js”が実行できます。


{
//略
  "scripts": {
    "start": "nodemon index.js"
  },
//略
}

index.jsファイルに以下のコードを記述します。Reactがポート番号の3000を利用するのでポート番号3001を設定しています。


const express = require('express');

const app = express();
const port = 3001;

app.get('/', (req, res) => res.send('Hello World!'));

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

index.jsが更新できたらnpm startコマンドを実行します。Expressサーバがポート番号3001で起動します。


 % npm start

> backend_node_server@1.0.0 start
> nodemon index.js

[nodemon] 2.0.19
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node index.js`
Example app listening on port 3001!

正常に動作しているかブラウザを利用してアクセスします。http://localhost:3001にアクセスして”Hello World”が表示されれば問題なく起動しています。

Prismaの設定

Prismaを利用するとExpressサーバからデータベースへの接続はPrismaを経由して行うことになります。PrismaはORM(Object Relational Mapping)ツールでオブジェクトとデータベースをマッピングできるためSQLではなくオブジェクトのメソッドを利用してデータベースを操作することができます。またデータベースとサーバとの間に入ることでデータベースの違いを吸収してくれるためデータベースを変更しても同じオブジェクトのメソッドを利用することができます。

ExpressからSQLiteデータベースを操作するためにはPrismaは必須ではありません。
fukidashi

Prismaを利用するためにnpmコマンドでインストールを行います。


 % npm install prisma

インストール後にPrismaの設定ファイルを作成するために初期化コマンド(npx prisma init)を実行します。接続するデータベースがSQLiteなのでオプションの–datasource-provider sqliteをつけて実行します。


 % npx prisma init --datasource-provider sqlite

コマンドを実行するとprismaフォルダと.envファイルが作成されます。prismaフォルダにはPrismaの設定ファイルschema.prismaが作成されます。schema.prismaファイルにデータベースへの接続情報やデータモデルを記述します。データモデルはテーブルの構成情報です。


// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

SQLiteの設定になっているので上記の設定はそのままでデータモデルを記述します。データモデルの記述はデータベースによって異なるのでテーブルに追加した列の型の設定方法やリレーションシップなど不明な場合はPrismaのドキュメントを参考に設定を行ってください。

ここではTodoテーブルを作成するので以下を記述します。Todoテーブルはid, name, isCompletedの3つの列を持ち、idは自動で順番に番号が付与されます。nameはTodoの名前で文字列、isCompletedはTodoが完了したかどうかを表し、trueやfalseのboolean値のみ設定できます。


generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Todo {
  id      Int      @id @default(autoincrement())
  name   String
  isCompleted    Boolean
}

schema.prismaの設定が完了したらデータベースファイル(SQLiteはファイルベースのデータベース)とテーブルを作成するためnpx prisma db pushコマンドを実行します。コマンドを実行するとprismaフォルダにdev.dbファイルが作成されます。


 % npx prisma db push

テーブルが作成できているかどうかはPrisma Studioを利用することができます。SQLiteのデータベースへの接続はコマンドによる接続やデータベースの管理ソフト(例:TablePlus)によっても行うことができます。


 % npx prisma studio
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555

Prisma Studioからはデータの閲覧だけではなくデータの追加もできるので2件のデータ追加を行います。Prisma Studioからはデータの追加だけではなく削除や更新も可能です。

Prisma Studioからデータを追加
Prisma Studioからデータを追加

ここまででPrismaの設定は初期データの作成は完了です。

データの取得

Prismaの設定が完了してデータベースのテーブルにデータを追加したので追加したデータをExpressサーバから取得します。index.jsファイルではimportしたPrismaClientを利用してデータベースへのアクセスを行います。prismaインスタンスにテーブル名を指定してfindManyメソッドを実行することでtodoテーブルにあるすべてのデータを取得することができます。何度も繰り返しますがPrismaを利用しているのでSQLではなくオブジェクトメソッドでデータベースを操作します。


const express = require('express');
const { PrismaClient } = require('@prisma/client');

const app = express();
const port = 3001;

const prisma = new PrismaClient();
app.get('/todos', async (req, res) => {
  const todos = await prisma.todo.findMany();
  return res.json(todos);
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

ブラウザからhttp://localhost:3001/todosにアクセスするとテーブルから取得したデータがJSONで表示されます。

データベースから取得したデータの表示
データベースから取得したデータの表示

Reactの設定

プロジェクトの作成

npx create-react-appコマンドを実行してReactプロジェクトを作成します。react-todo-appは任意の名前なので好きな名前を指定して実行してください。


 % npx create-react-app react-todo-app

コマンドを実行後にreact-todo-appに移動してTanStack Queryのインストールを行います。

TanStack Queryのインストール

TanStack Queryのインストールはnpmコマンドで行います。


 % npm i @tanstack/react-query

インストール後にreactとtanstack/react-queryのバージョンをpackage.jsonファイルで確認します。


{
  "name": "react-todo-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@tanstack/react-query": "^4.2.3",
    "@testing-library/jest-dom": "^5.14.1",
    "@testing-library/react": "^13.0.0",
    "@testing-library/user-event": "^13.2.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-scripts": "5.0.1",
    "web-vitals": "^2.1.0"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
  },
//略
}

Reactは18.2.0で、tanstack/react-queryは4.2.3であることがわかります。このバージョンを利用して動作確認を行います。

Todoコンポーネントの作成

srcフォルダの下にcomponentsフォルダを作成してTodo.jsxファイルを作成します。


const Todo = () => {
  return (
    <>
      <h1>Todo一覧</h1>
    </>
  );
};

export default Todo;

作成したTodoコンポーネントは App.jsファイルでimportを行います。


import Todo from './components/Todo';
import './App.css';

function App() {
  return (
    <div className="App">
      <Todo />
    </div>
  );
}

export default App;

npm startコマンドで開発サーバを起動します。


 % npm start
Compiled successfully!

You can now view react-todo-app in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://192.168.2.114:3000

Note that the development build is not optimized.
To create a production build, use yarn build.

webpack compiled successfully

ブラウザからアクセスすると画面にはTodo一覧が表示されます。

Todo一覧の表示
Todo一覧の表示

TanStack Queryの設定

TodoコンポーネントからTanStack Queryを利用してサーバからTodoのデータを取得します。データ取得に利用するuseQuery Hookを利用したコードは下記の通りです。


import { useQuery } from '@tanstack/react-query';

const fetchTodos = async () => {
  const res = await fetch('http://localhost:3001/todos');
  return res.json();
};

const Todo = () => {
  const { data: todos } = useQuery(['todos'], fetchTodos);

  return (
    <>
      <h1>Todo一覧</h1>
      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
    </>
  );
};

export default Todo;

TanStack QueryではimportしたuseQuery Hookを利用してサーバへのQueryを実行します。useQueryには第一引数に配列でユニークなキーを設定し第二引数にPromiseを戻す関数を設定します。第三引数にも設定が可能でオブジェクトを利用してオプションを設定することができます。第二引数に設定したfetchTodos関数ではfetch関数を利用してサーバからデータを取得しています。

fetch関数ではなくaxiosライブラリを利用した場合については後ほど説明を行います。
fukidashi

useQueryによるデータ取得の動作確認を行うためブラウザからアクセスするとブラウザのコンソールには”Uncaught Error: No QueryClient set, use QueryClientProvider to set one”のメッセージが表示されます。useQueryを利用するためにはここまでの設定では不足しており、QueryClientとQueryClientProviderが必要であることがわかります。

App.jsファイルでQueryClientProvider、QueryClientの設定を行います。TodoコンポーネントでuseQueryを利用するためにはTodoコンポーネントをQueryClientProviderでwrapする必要があります。


import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Todo from './components/Todo';
import './App.css';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <Todo />
      </div>
    </QueryClientProvider>
  );
}

export default App;

再度ブラウザからアクセスするとTodo一覧の文字列のみ表示されます。これはTanStack Query側の問題ではなくブラウザのコンソールを見るとCORSのエラーが発生しているのでサーバ側でCORSの設定を行う必要があります。

CORSはCross Origin Resource Sharing(オリジナ間リソース共有)の略でReactの起動ポートとExressサーバの起動ポートが異なるため異なるオリジンとみなされ接続することができません。CORSを設定することで異なるオリジンでの通信が可能となります。

backendフォルダでcorsのインストールを行います。Reactのプロジェクト側ではないので注意してください。


 % npm install cors

インストールが完了したらindex.jsファイルにcorsの設定を追加します。


const express = require('express');
const cors = require('cors');
const { PrismaClient } = require('@prisma/client');

const app = express();
const port = 3001;

app.use(cors());

const prisma = new PrismaClient();
app.get('/todos', async (req, res) => {
  const todos = await prisma.todo.findMany();
  return res.json(todos);
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

サーバ側でのCORSの設定完了後、ブラウザからアクセスするとuseQueryを利用して取得したサーバ上のデータがブラウザ上に表示されます。

サーバから取得todosデータを表示
サーバから取得todosデータを表示

データを取得するだけならTanStack Queryを利用しなくてもfetch関数、axios単体でも行うことができます。useQueryで利用できる情報を利用してfetch関数やaxiosとの違いを確認します。

useQueryの戻り値のオブジェクトの中からdataだけを利用していましたがほかにもさまざまな情報が戻されます。どのようなデータが戻されるのかはTansStack Queryのドキュメントで確認することができます。

useQuery API
useQuery API

その中からisLoading, isError, errorを使った場合の利用方法を確認します。isLoading, isErrorはbooleanの値を持ち、errorについてはエラーがthrowされた場合にオブジェクトとしてエラーの情報を含みます。

isLoading, isError, errorを利用する場合は以下のようにコードを記述することができます。isLoadingやisErrorの値によってユーザに表示させる内容を変更することができます。


import { useQuery } from '@tanstack/react-query';

const fetchTodoList = async () => {
  const res = await fetch('http://localhost:3001/todos');
  return res.json();
};

const Todo = () => {
  const {
    isLoading,
    isError,
    data: todos,
    error,
  } = useQuery(['todos'], fetchTodoList);

  if (isLoading) {
    return <span>Loading...</span>;
  }

  if (isError) {
    return <span>Error: {error.message}</span>;
  }

  return (
    <>
      <h1>Todo一覧</h1>
      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
    </>
  );
};

export default Todo;

useQueryによるデータの取得中にはブラウザ中に”isLoading”が表示されますが一瞬です。isLoadingの画面を長めに表示させたい場合にはブラウザのデベロッパーツールのスロットリングを低速3Gに変更することで可能になります。

ネットワークの速度を変更
ネットワークの速度を変更

低速3Gに変更した後には”isLoading”がしっかり確認できるはずです。

データ取得中に表示されるLoading
データ取得中に表示されるLoading

エラーが発生した場合の動作確認も行いたいのでExpreeサーバ側で意図的にステータスコード500を戻すように設定を行います。


app.get('/todos', async (req, res) => {
  return res.status(500).json({ message: 'Internal Server Error' });
  // const todos = await prisma.todo.findMany();
  // return res.json(todos);
});

画面にエラーが表示させるではと思いますが残念ながら画面には何も表示されずブラウザのコンソールを見ると下記のメッセージが表示されます。


GET http://localhost:3001/todos 500 (Internal Server Error)
Uncaught TypeError: todos.map is not a function

Todo.jsxファイルで設定したisErrorの条件分岐をパスしてtodosのmap関数の処理でエラーになっています。todosの中身をconsole.logで確認するとサーバから戻されたエラーが文字列としてそのまま入っているためmap関数を利用して展開することはできません。


"{message: 'Internal Server Error'}"

useQueryの処理ではfetchを利用しているのでリクエストが成功したかどうかをfetch関数実行後に戻されるresponseオブジェクトのokプロパティ(response.ok)で確認する必要があります。fetchでは400, 500などのステータスコードが戻されたとしてもそのまま処理を行うためresponse.okで確認を行うことでエラーを検知させます。ステータスコードが200の場合はresponse.okはtrueになりますが400や500ではresponse.okはfalseとなります。


const fetchTodoList = async () => {
  const res = await fetch('http://localhost:3001/todos');
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}`);
  }
  return res.json();
};

res.okの分岐を入れたことでステータスコード500が戻され場合はres.okがfalseとなりエラーがthrowされます。エラーがthrowされたことによりisErrorがtrueとなりブラウザ上にはエラーメッセージが表示されます。

エラーメッセージの表示
エラーメッセージの表示

エラーが表示されることが確認できましたがLoaing画面が表示されてからエラーメッセージが表示されるまで少し時間がかかります。useQueryではエラーが発生しても複数回自動でリトライを行います。リトライはオプションで設定することができデフォルトでは3に設定されています。リトライしてもエラーが発生した場合にエラーメッセージが画面に表示されます。

リトライの確認
リトライの確認

useQueryはfetch, axiosを単体で利用した場合とは異なり、データだけではなくisLoading, isError, errorなどの情報を利用できることがわかりました。またリトライなどの機能を備えていることもわかります。

エラーの処理と表示内容について

表示されているエラーの内容をサーバ側で設定したメッセージにしたい場合はresponse.json()やresponse.text()を利用することができます。サーバが戻されるメッセージがサーバ側からだとわかるようにメッセージの内容を変更しておきます。


app.get('/todos', async (req, res) => {
  return res.status(500).json({ message: 'Internal Server Error From Server' });
});

res.okがfalseの場合もres.jsonを利用して戻されるデータを取得します。res.jsonから戻されるjsonにはサーバから戻されたJSONデータがオブジェクトに変換されて入っているためmessageプロパティでメッセージの内容にアクセスすることができます。


const fetchTodoList = async () => {
  const res = await fetch('http://localhost:3001/todos');
  if (!res.ok) {
    const json = await res.json();
    throw new Error(json.message);
  }
  return res.json();
};

エラーが発生した場合、先程のメッセージとは異なりサーバ上で設定したmessageの値が表示されるようになりました。

サーバから戻されたエラーメッセージを表示
サーバから戻されたエラーメッセージを表示

res.json()ではオブジェクトとしてデータを取り出すことができますがres.text()の場合はテキスト(JSONデータ)として取得することができます。


const fetchTodoList = async () => {
  const res = await fetch('http://localhost:3001/todos');
  if (!res.ok) {
    const text = await res.text();
    throw new Error(text);
  }
  return res.json();
};

textは文字列なのでそのまま表示させると下記のように表示されます。

respnose.text()を利用した場合
respnose.text()を利用した場合

useQueryで戻されるerrorのmessageにはthrowしたres.text()がJSONデータとして保存されているのでJSON.parseを利用してオブジェクトに変換してmessageプロパティの内容を表示することも可能です。


if (isError) {
  const text = JSON.parse(error.message);
  return Error: {text.message};
}

下記のように表示されます。サーバから戻されるJSONデータが複雑な場合もJSON.parseでオブジェクトに変換することで必要な情報をブラウザ上に表示させることができます。

サーバから戻されたエラーメッセージを表示
サーバから戻されたエラーメッセージを表示

サーバから戻されるエラーの表示方法について確認することができました。

axiosを利用した場合

データを取得する際にfetch関数ではなくaxiosライブラリを利用することができます。axiosを利用した場合にはサーバからステータスコードの400や500が戻された場合にはエラーの処理を行ってくれるので違いを確認しておきます。

axiosを利用したい場合はaxiosのインストールが必要になります。Reactのプロジェクトでインストールを行います。


% npm install axios

fetchTodoList関数をfetchからaxiosに変更します。エラーがない場合は下記のコードでユーザ一覧が表示されます。


const fetchTodoList = async () => {
  const res = await axios.get('http://localhost:3001/todos');
  return res.data;
};

エラーが発生した場合にどのようなエラーが渡されるのか確認します。


if (isError) {
  console.log(error);
  return <span>Error: {error.message}</span>;
}

コンソールを確認するとAxiosErrorオブジェクトが表示されます。


AxiosError {message: 'Request failed with status code 500', name: 'AxiosError', code: 'ERR_BAD_RESPONSE', config: {…}, request: XMLHttpRequest, …}
code: "ERR_BAD_RESPONSE"
config: {transitional: {…}, transformRequest: Array(1), transformResponse: Array(1), timeout: 0, adapter: ƒ, …}
message: "Request failed with status code 500"
name: "AxiosError"
request: XMLHttpRequest {onreadystatechange: null, readyState: 4, timeout: 0, withCredentials: false, upload: XMLHttpRequestUpload, …}
response: {data: {…}, status: 500, statusText: 'Internal Server Error', headers: {…}, config: {…}, …}
[[Prototype]]: Error

ブラウザを確認するとAixosErrorオブジェクトのmessageプロパティの内容が表示されます。

AxiosErrorのmessageプロパティの内容が表示
AxiosErrorのmessageプロパティの内容が表示

axiosの場合はサーバから戻されるデータはresponseオブジェクトのdataに含まれているのでサーバからのメッセージを表示したい場合にはresponose.dataを利用することができます。


if (isError) {
  return <span>Error: {error.response.data.message}</span>;
}
サーバから戻されたエラーメッセージを表示
サーバから戻されたエラーメッセージを表示

axiosとfetchの設定方法の違いとエラーの表示方法について確認することができました。

データの追加/更新/削除の方法はfetch関数を利用して行いますがaxiosを利用しても可能です。

Todoの追加方法

データベースに保存されているデータの表示方法を確認することができたので次はデータの追加方法について確認します。

useState Hookを利用したフォームの作成

Todoリストに新たにTodoを追加するためにはTodoの名前を設定する入力フォームが必要となります。

Todoの名前を入力するためのフォームを追加します。追加したフォームの入力値はuseState Hookを利用して管理します。


import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

const fetchTodoList = async () => {
  const res = await fetch('http://localhost:3001/todos');
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}`);
  }
  return res.json();
};

const Todo = () => {
  const [name, setName] = useState('');

  const handleChange = (e) => {
    setName(e.target.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log(name);
  };

  const {
    isLoading,
    isError,
    data: todos,
    error,
  } = useQuery(['todos'], fetchTodoList);

  if (isLoading) {
    return <span>Loading...</span>;
  }

  if (isError) {
    return <span>Error: {error.message}</span>;
  }

  return (
    <>
      <h1>Todo一覧</h1>
      <div>
        <form onSubmit={handleSubmit}>
          Add Todo :
          <input
            placeholder="Add New Todo"
            value={name}
            onChange={handleChange}
          />
          <button>追加</button>
        </form>
      </div>
      <ul>
        {todos?.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
    </>
  );
};

export default Todo;

設定後ブラウザ上にinputフィールドが表示され、Todoの名前を入力して追加ボタンをクリックするとブラウザのコンソールには入力した文字列が表示されます。

Todo追加用の入力フォームを作成
Todo追加用の入力フォームを作成

ここから入力した値をuseMutationを利用してサーバに送信し、データベーステーブルへのTodoの追加を行います。

サーバ側での追加処理

フロントエンド(React)から送信されてくるPOSTリクエストのデータを利用してデータベーステーブルへのデータの作成処理を追加します。/todos/createのURLに対してPOSTリクエストがあるとreq.bodyからnameを取り出します。Prismaではprisma.テーブル名が持つcreateメソッドでデータの作成を行うことができます。通常のSQLであればinsert文を利用します。isCompletedプロパティはTodo作成直後はfalseとしています。


app.post('/todos/create', async (req, res) => {
  const { name } = req.body;
  const todo = await prisma.todo.create({
    data: {
      name,
      isCompleted: false,
    },
  });
  return res.json(todo);
});

useMutationの設定

useMutationを利用しAddMutationを定義します。useMutationの第一引数には実行する関数を記述します。関数にはaddTodoを設定しているのでaddTodo関数を定義しています。addTodoではfetch関数でPOSTリクエストを送信するためmethodにはPOSTを指定しbodyにはフォームで入力したnameを設定します。


//略
const Todo = () => {
  const [name, setName] = useState('');

  const addTodo = async () => {
    const res = await fetch('http://localhost:3001/todos/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        name,
      }),
    });
    if (!res.ok) {
      throw new Error(`${res.status} ${res.statusText}`);
    }
    return res.json();
  };

  const addMutation = useMutation(addTodo);
//略

useMutationの戻り値にはuseQueryと同様にさまざまプロパティが含まれています。

useMuattionのマニュアル
useMuattionのマニュアル

定義したaddMutationを実行したい場合はmutate関数を利用します。追加ボタンをクリックした後に実行されるhandleSubmit関数の中で実行します。


const handleSubmit = (e) => {
  e.preventDefault();
  addMutation.mutate();
};

useMutation(addTodo)の戻り値をaddMutationに保存していますがuseQueryと同様に下記のように記述することもできます。


const { mutate } = useMutation(addTodo);

inputフィールドに文字列を入力して”追加”ボタンを押しても画面には何も変化はありません。リロードもしくは一度カーソルをブラウザから外して再度画面をクリックするとリフェッチが行われるので追加した文字列が表示されます。

useMutationでデータを追加
useMutationでデータを追加
カーソルを一度ブラウザから外してブラウザの画面をクリックするとリフェッチが行われます。これはWindow Focus Refetching機能と呼ばれデフォルトでOnになっています。オプションのrefetchOnWindowFocus で制御できます。useQueryとfetchyやaxiosとの違いの一つです。
fukidashi

追加ボタンをクリックした後にリロードなどを行わずに追加したデータをブラウザ上に反映させるためにオプションを利用することができます。オプションにはPOSTリクエスト成功時に実行されるonSuccess, エラー時に実行させるonError, 成功、エラーに関わらず実行されるonSettled、POSTリクエスト実行前に実行されるonMutateなどがありますがonSuccessを利用して追加したデータをブラウザ上に反映させます。


import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
//略
const Todo = () => {
  const [name, setName] = useState('');
  const queryClient = useQueryClient();
//略
  const addMutation = useMutation(addTodo, {
    onSuccess: () => {
      queryClient.invalidateQueries('todos');
    },
  });
//略
オプションのonSuccess, onError, onSettledはuseQueryでも利用することができます。
fukidashi

onSuccessの中ではimportしたuseQueryClientから戻されるqueryClientのinvalidateQueriesメソッドを実行します。invalidateQueryiesの引数にuseQueryで指定したキーtodosを設定することでキャッシュにあるtodosのデータを無効にし、キーtodosを持つuseQueryを利用してリフェッチさせることができます。

実際に動作確認するとinputフィールドにTodoの名前を入力し”追加”ボタンをクリックし、サーバ側でデータの作成処理が成功すると追加したデータがTodoリストに追加され表示されます。リロードなどは必要ありません。

onMutate, onSuccess, onError, onSettledの動作確認

オプションのonMutate, onSuccess, onError, onSettledはほかでも利用することができるのでどのタイミングで実行されるか確認するためにaddMutationの処理を利用します。


const addMutation = useMutation(addTodo, {
  onMutate: () => {
    console.log('onMutate');
  },
  onSuccess: () => {
    console.log('onSuccess');
  },
  onError: () => {
    console.log('onError');
  },
  onSettled: () => {
    console.log('onSettled');
  },
});

リクエストに成功した場合にはコンソールにonMutate, onSuccess, onSetlledが順番に表示されます。

リクエストに失敗した場合ににコンソールにはonMutateが表示され、リクエストのエラーが表示された後にonError, onSettledが表示されます。

onSuccessは名前の通りリクエストに成功した場合のみonErrorはリクエストの失敗した場合に実行され、onMutateはリクエストの前、onSettledはリクエストの成功、失敗に関わらず実行されるということがわかりました。

4つのオプションではそれぞれ引数を持っており引数に入った値を利用することができます。

onSuccessにはdata, variables, contextの3つの引数を取ることができるのでどのような値が取得できるのか確認しておきましょう。


const addMutation = useMutation(addTodo, {
  onMutate: () => {
    console.log('onMutate');
  },
  onSuccess: (data, variables, context) => {
    console.log('data', data);
    console.log('variables', variables);
    console.log('context', context);
    console.log('onSuccess');
  },
  onError: () => {
    console.log('onError');
  },
  onSettled: () => {
    console.log('onSettled');
  },
});

variablesの値はmutateメソッドを実行する際に引数を設定することで取得できるのでmutateメソッドの引数を設定しておきます。


const handleSubmit = (e) => {
  e.preventDefault();
  addMutation.mutate({ id: 1 });
};

Todoの追加を行うとdataにはuseMutationで設定したaddTodoの戻り値、variablesにはmutate関数で設定した引数の値が取得できることがわかります。しかしcontextはundefinedです。


onMutate
data {id: 20, name: 'aaaa', isCompleted: false}
variables {id: 1}
context undefined
onSuccess
onSettled

contextはonMutateがreturnした値を受け取ることができます。onMutateは引数でvariablesを受け取ることができるのでそのままvariablesをreturnします。


const addMutation = useMutation(addTodo, {
  onMutate: (variables) => {
    console.log('onMutate');
    return variables;
  },
  onSuccess: (data, variables, context) => {
    console.log('data', data);
    console.log('variables', variables);
    console.log('context', context);
    console.log('onSuccess');
  },
  onError: () => {
    console.log('onError');
  },
  onSettled: () => {
    console.log('onSettled');
  },
});

contextの値は先ほどはundefinedでしたが、onMutateでreturnした値を受け取れることが確認できます。


onMutate
data {id: 20, name: 'aaaa', isCompleted: false}
variables {id: 1}
context {id: 1}
onSuccess
onSettled

onErrorではerror, variables, contextをonSettledはdata, error, variables, contextを受け取ることができます。


const addMutation = useMutation(addTodo, {
  onMutate: (variables) => {
    console.log('onMutate');
    return variables;
  },
  onSuccess: (data, variables, context) => {
    console.log('data', data);
    console.log('variables', variables);
    console.log('context', context);
    console.log('onSuccess');
  },
  onError: (error, variables, context) => {
    console.log('error', error);
    console.log('variables', variables);
    console.log('context', context);
    console.log('onError');
  },
  onSettled: () => {
    console.log('onSettled');
  },
});

リクエストに失敗するとonErrorではエラーが受け取れることはわかります。


onMutate
POST http://localhost:3001/todos/create 400 (Bad Request)
error 400 Bad Request
variables {id: 1}
context {id: 1}
onError
onSettled

onMutate, onSuccess, onError, onSettledでどのような値が引数から受け取るかということがわかりました。オプションの利用方法については後ほどOptimistic Updateの箇所で説明します。

Todoの削除方法

TodoリストからTodoを削除できるように削除ボタンの追加を行います。ボタンにはonClickイベントを追加し、handleRemoveTodo関数を設定し引数にはtodoのidを設定しています。


<ul>
  {todos?.map((todo) => (
    <li key={todo.id}>
      {todo.name}
      <button
        style={{ marginLeft: '0.2em', cursor: 'pointer' }}
        onClick={() => handleRemoveTodo(todo.id)}
      >
        X
      </button>
    </li>
  ))}
</ul>

handleRemoveTodo関数に処理を追加します。動作確認を行うため実行するとコンソールに受け取ったidを表示させています。


const handleRemoveTodo = (id) => {
  console.log(id);
};

ブラウザで確認するとTodoの横の”X”ボタンをクリックするとコンソールにTodoのidが表示されます。

削除ボタンを追加
削除ボタンを追加

削除を行う際もuseMutationを利用して行います。fetch関数のmethodにはDELETEを設定しhandleRemoveTodoの引数から受け取ったTodoのidをmutate関数の引数に設定してdeleteTodo関数の引数で受け取っています。addMutationと同様にオプションonSuccessでqueryClient.invalidateQueriesでサーバからtodosのリフェッチを行っています。


//略
const deleteTodo = async (id) => {
  const res = await fetch(`http://localhost:3001/todos/${id}`, {
    method: 'DELETE',
  });
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}`);
  }
  return res.json();
};

const deleteMutation = useMutation(deleteTodo, {
  onSuccess: () => {
    queryClient.invalidateQueries('todos');
  },
});

//略

const handleRemoveTodo = (id) => {
  deleteMutation.mutate(id);
};

設定が完了してもサーバ側でのルーティングと削除処理が設定されていないためエラーになります。

サーバ側での追加処理

URLに含まれる動的なidはreq.paramsから取得し、取得したidを利用してtodoテーブルから削除処理を行っています。idは文字列なのでNumberを使って数値に変換しています。prisma.todoのdeleteメソッドのwhereでidを指定してそのidを持つデータを削除しています。


app.delete('/todos/:id', async (req, res) => {
  const { id } = req.params;
  const todo = await prisma.todo.delete({
    where: {
      id: Number(id),
    },
  });
  return res.json(todo);
});

削除の動作確認

サーバ側の削除処理が完了すると”X”ボタンをクリックするとクリックしたTodoが削除されます。

Xボタンをクリック後の削除した後のリスト
Xボタンをクリック後の削除した後のリスト

Todoの作成処理と削除処理をuseMutationを利用して行うことができました。

Todoの更新方法

Todoの更新方法を確認するためにチェックボックスを追加します。チェックボックスにチェックをつけたり外したりすることでTodoのisCompletedプロパティの値を更新します。

Todoリストにチェックボックスを追加するためにinput要素を追加します。input要素のtypeは”checkbox”に設定します。onChangeイベントを追加してhandleCheckChange関数を設定します。引数にはisCompletedの値が現在の値とは逆の値を設定します。


<ul>
  {todos?.map((todo) => (
    <li
      key={todo.id}
    >
      <input
        type="checkbox"
        onChange={() =>
          handleCheckChange({ ...todo, isCompleted: !todo.isCompleted })
        }
      />
      {todo.name}
      <button
        style={{ marginLeft: '0.2em', cursor: 'pointer' }}
        onClick={() => handleRemoveTodo(todo.id)}
      >
        X
      </button>
    </li>
  ))}
</ul>

handleCheckChange関数を追加します。


const handleCheckChange = (todo) => {
  console.log(todo)
};

ブラウザ上にはチェックボックスが表示されチェックをつけたり、外したりするとチェックボックスを操作したTodoの情報がコンソールに表示されます。

checkboxの表示
checkboxの表示

デフォルトではすべてのTodoのisCompletedの値はfalseですがtrueの場合にはページを開いた場合にチェックした状態にするためchecked属性にisCompletedの値を設定します。


<input
  type="checkbox"
  checked={todo.isCompleted}
  onChange={() =>
    handleCheckChange({ ...todo, isCompleted: !todo.isCompleted })
  }
/>

checked属性を設定するとチェックボックスにチェックができなくなります。チェックできるようにするためにはuseMutationを利用してisCompletedの値を更新できるようにする必要があります。

useMutationの引数にはupdateTodo関数を設定します。updateTodo関数ではのfetch関数ではmethodにPATCHを設定してサーバにtodoの情報を送信します。handleCheckChange関数ではupdateMutationのmutateメソッドの引数にtodoを設定します。


//略
const updateTodo = async (todo) => {
  const res = await fetch(`http://localhost:3001/todos/${todo.id}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(todo),
  });
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}`);
  }
  return res.json();
};

//略

const updateMutation = useMutation(updateTodo, {
  onSuccess: () => {
    queryClient.invalidateQueries('todos');
  },
});

//略
const handleCheckChange = (todo) => {
  updateMutation.mutate(todo);
};

設定が完了してもサーバ側でのルーティングと更新理が設定されていないためエラーになります。

サーバ側での更新処理

URLに含まれる動的なidはreq.paramsから取得し、更新を行うtodoの内容はreq.bodyから取得します。取得したidを利用して更新処理を行っています。idは文字列なのでNumberを使って数値に変換しています。prisma.todoのupdateメソッドを利用してデータの更新を行います。更新するデータを見つけるためにidを利用しています。更新するデータはisCompletedの値のみです。


app.patch('/todos/:id', async (req, res) => {
  const { id } = req.params;
  const { isCompleted } = req.body;
  const todo = await prisma.todo.update({
    where: {
      id: Number(id),
    },
    data: {
      isCompleted,
    },
  });
  return res.json(todo);
});

更新の動作確認

チェックボックスを押すとチェックをつけたり外したりできるようになります。チェック後にリロードしてもisCompletedがtureに変更したものはチェックがついた状態で表示されます。

checkboxの表示
checkboxの表示

さらに完了したTodoがチェックボックスだけではなく横棒が引かれるようにスタイルの設定を行います。


<ul>
  {todos?.map((todo) => (
    <li
      key={todo.id}
      style={
        todo.isCompleted === true
          ? { textDecorationLine: 'line-through' }
          : {}
      }
    >
      <input
        type="checkbox"
        checked={todo.isCompleted}
        onChange={() =>
          handleCheckChange({ ...todo, isCompleted: !todo.isCompleted })
        }
      />
      {todo.name}
      <button
        style={{ marginLeft: '0.2em', cursor: 'pointer' }}
        onClick={() => handleRemoveTodo(todo.id)}
      >
        X
      </button>
    </li>
  ))}
</ul>

チェックだけではなく完了したTodoには横棒が引かれるようになりました。

完了したTodoには横棒が引かれる
完了したTodoには横棒が引かれる

シンプルなTodoですが、TanStack QueryのuseQueryとuseMutationを利用して取得/作成/更新/削除のCRUDが行えるようになりました。

エラーの処理について

useQueryについてはisErrorを分岐条件として利用することでエラーが発生した場合には画面上にエラーを表示させてきました。useMutationの場合も同様にuseMutationの戻り値にisErrorやerrorが含まれています。今回のコードであればaddMutation.isError, updateMutation.isError, deleteMutation.isErrorでエラーがあるかチェックしてエラーの内容はaddMutation.error、updateMutation.error, deleteMutation.errorで取得することができます。

Optimistic Updates

サーバにリクエストを送信しサーバ上のデータ更新が成功したことを確認する前にブラウザ上の表示をサーバでの更新が反映された形に更新することをOptimistic Updatesと呼びます。サーバ上での更新が失敗することもあるので失敗した場合に備え更新を取り消す処理を一連の処理の流れの中に加えておく必要があります。

TanStackでもOptimistic Updatesを実装することが可能で本文書でも確認したonMutate, onError, onSettledオプションなどを利用します。

Optimistic UpdatesについてはTanStackのドキュメントに掲載されているのでその手順を利用して動作確認を行います。

TanStack QueryでのOptimistic UpdatesではonMutateを利用してキャッシュ上のデータを更新します。onMutateはリクエストを送信する前に実行されます。キャッシュを更新するだけではなく失敗に備えて更新前の情報をcontextを利用してonErrorに渡します。失敗した場合にはonErrorの処理が実行されcontextに含まれる値(onMutateから渡された更新前の情報)を利用してキャッシュを元の状態に戻します。onSettledはリクエストの成功、失敗に関わらず必ず実行されるのでnvalidateQueriesを実行してリフェッチを行います。

handleSubmitの処理に変更を加えます。先ほどはmutateメソッドの引数はありませんでしたがonMutateに作成するTodoの情報を渡すために引数を設定します。


const handleSubmit = (e) => {
  e.preventDefault();
  addMutation.mutate({ name, isCompleted: false });
};

先ほど言葉で説明した手順をコードにすると下記のようになります。コードに残されているコメントはTanStack Queryドキュメントに記載されているそのままの情報です。cancelQueriesではこれから行うキャッシュの更新が上書きされないようにQueryをキャンセルしています。getQueryDataで現在のキャッシュの情報を取得しています。更新前のデータなのでpreviousTodosという名前になっています。setQueryDataでキャッシュの情報を更新しています。更新前の情報はreturnしています。これはリクエストが失敗した時にonErrrorで利用されます。失敗した場合はonErrorの中でsetQueryDataで更新前の情報でキャッシュを更新しています(更新前の状態に戻しています)。onSettledではinvalidateQueriesによりリフェッチを行っています。


const addMutation = useMutation(addTodo, {
  onMutate: async (todo) => {
    await queryClient.cancelQueries(['todos']);

    // Snapshot the previous value
    const previousTodos = queryClient.getQueryData(['todos']);

    // Optimistically update to the new value
    queryClient.setQueryData(['todos'], (old) => [...old, todo]);

    // Return a context object with the snapshotted value
    return { previousTodos };
  },
  onError: (err, todo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    queryClient.invalidateQueries('todos');
  },
});

成功した場合と失敗した場合の動作確認を行います。

成功する場合は追加ボタンをクリックすると即座にリストに入力した文字列が表示されます。その後リフェッチが行われますが画面では更新済みなので変化は何もありません。

意図的にサーバ側でエラーを発生させた場合は”追加”ボタンをクリックすると即座にリストに入力した文字列が表示されます。

即座にリストに追加される
即座にリストに追加される

リクエストが失敗したことが判明すると追加されたTodoがブラウザ上から消えます。

完了したTodoには横棒が引かれる
リクエストが失敗し元の状態に戻る

Optimistic Updatesがどのような動作するのか実際にコードを利用して確認することができました。

作成したコード

React側のコードTodo.jsxです。


import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';

const fetchTodoList = async () => {
  const res = await fetch('http://localhost:3001/todos');
  if (!res.ok) {
    throw new Error(`${res.status} ${res.statusText}`);
  }
  return res.json();
};

const Todo = () => {
  const [name, setName] = useState('');
  const queryClient = useQueryClient();

  const addTodo = async (todo) => {
    const res = await fetch('http://localhost:3001/todos/create', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(todo),
    });
    if (!res.ok) {
      throw new Error(`${res.status} ${res.statusText}`);
    }
    return res.json();
  };

  const deleteTodo = async (id) => {
    const res = await fetch(`http://localhost:3001/todos/${id}`, {
      method: 'DELETE',
    });
    if (!res.ok) {
      console.log('res', res);
      throw new Error(`${res.status} ${res.statusText}`);
    }
    return res.json();
  };

  const updateTodo = async (todo) => {
    const res = await fetch(`http://localhost:3001/todos/${todo.id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(todo),
    });
    if (!res.ok) {
      throw new Error(`${res.status} ${res.statusText}`);
    }
    return res.json();
  };

  const addMutation = useMutation(addTodo, {
    onMutate: async (todo) => {
      await queryClient.cancelQueries(['todos']);

      // Snapshot the previous value
      const previousTodos = queryClient.getQueryData(['todos']);

      // Optimistically update to the new value
      queryClient.setQueryData(['todos'], (old) => [...old, todo]);

      // Return a context object with the snapshotted value
      return { previousTodos };
    },
    onError: (err, todo, context) => {
      queryClient.setQueryData(['todos'], context.previousTodos);
    },
    onSettled: () => {
      queryClient.invalidateQueries('todos');
    },
  });

  const deleteMutation = useMutation(deleteTodo, {
    onSuccess: () => {
      queryClient.invalidateQueries('todos');
    },
  });

  const updateMutation = useMutation(updateTodo, {
    onSuccess: () => {
      queryClient.invalidateQueries('todos');
    },
  });

  const handleChange = (e) => {
    setName(e.target.value);
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    addMutation.mutate({ name, isCompleted: false });
  };

  const handleRemoveTodo = (id) => {
    deleteMutation.mutate(id);
  };

  const handleCheckChange = (todo) => {
    updateMutation.mutate(todo);
  };

  const {
    isLoading,
    isError,
    data: todos,
    error,
  } = useQuery(['todos'], fetchTodoList);

  if (isLoading) {
    return <span>Loading...</span>;
  }

  if (isError) {
    return <span>Error: {error.message}</span>;
  }

  return (
    <>
      <h1>Todo一覧</h1>
      <div>
        <form onSubmit={handleSubmit}>
          <label htmlFor="name">Add Todo :</label>
          <input
            placeholder="Add New Todo"
            value={name}
            onChange={handleChange}
            id="name"
          />
          <button>追加</button>
        </form>
      </div>
      <ul>
        {todos?.map((todo) => (
          <li
            key={todo.id}
            style={
              todo.isCompleted === true
                ? { textDecorationLine: 'line-through' }
                : {}
            }
          >
            <input
              type="checkbox"
              checked={todo.isCompleted}
              onChange={() =>
                handleCheckChange({ ...todo, isCompleted: !todo.isCompleted })
              }
            />
            {todo.name}
            <button
              style={{ marginLeft: '0.2em', cursor: 'pointer' }}
              onClick={() => handleRemoveTodo(todo.id)}
            >
              X
            </button>
          </li>
        ))}
      </ul>
    </>
  );
};

export default Todo;

バックエンド側のコードindex.jsです。


const express = require('express');
const cors = require('cors');
const { PrismaClient } = require('@prisma/client');

const app = express();

const port = 3001;

app.use(cors());
app.use(express.json());

const prisma = new PrismaClient();

app.get('/todos', async (req, res) => {
  // return res.status(500).json({ message: 'Internal Server Error From Server' });
  const todos = await prisma.todo.findMany();
  return res.json(todos);
});

app.post('/todos/create', async (req, res) => {
  // return res.status(400).json({ message: 'name is required' });

  const { name } = req.body;
  const todo = await prisma.todo.create({
    data: {
      name,
      isCompleted: false,
    },
  });
  return res.json(todo);
});

app.delete('/todos/:id', async (req, res) => {
  const { id } = req.params;
  const todo = await prisma.todo.delete({
    where: {
      id: Number(id),
    },
  });
  return res.json(todo);
});

app.patch('/todos/:id', async (req, res) => {
  const { id } = req.params;
  const { isCompleted } = req.body;
  const todo = await prisma.todo.update({
    where: {
      id: Number(id),
    },
    data: {
      isCompleted,
    },
  });
  return res.json(todo);
});

app.use((req, res, next) => {
  res.status(404).json({
    message: 'Oops! Something is wrong.',
  });
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));