はじめに
昨今のフロントエンド実装では、State管理がとても重要になります。その中でも個人的に面白いなと思うのが、キャッシュを活用したリモートデータのState管理です。データフェッチ系のライブラリではデファクトスタンダードになりつつあると感じています。
React QueryやVercel製のSWR、GraphQLではお馴染みのApollo Clientの最新メジャーバージョンでは、標準的に実装されている機能です。このキャッシュを活用したリモートデータのState管理をToDoアプリケーションに組み込んでいってみます。本記事では一番メジャーであるReactQueryを利用します。
なお、本記事に出てくる実装案はあくまでもサンプルなので、そのままアプリケーションに組み込んでうまくいくことを保証するものではありませんので、悪しからず。
リモートデータのState管理とは?
クライアントとは別の箇所に永続化されたデータをクライアントで利用するための手法です。DBとか、他サーバーからAPIでデータを取得する場合などもよくある例ですね。
リモートデータを利用するとどうしても、ローディング時間の増加でユーザー体験を損ねてしまうことがあります。キャッシュをうまく活用することで、ローディング時間を削減し、ユーザー体験を向上させることが狙いです。
そして、それと同時に開発者の開発体験の向上も狙っています。逐一再フェッチする処理を記述したりしなくて済むのはとても助かります。特にコンポーネントが何階層にも跨る場合は、尚更です。
ReactQueryについて
本記事では、データフェッチライブラリとしてReact Queryを利用します。React界隈では最もメジャーといって差し支えないでしょう。
hooksベースで利用できます。特徴としては、非同期なデータフェッチ関数に対してWrapする形で利用するというところです。そのため、RESTでもGraphQLでも利用することができるので、柔軟性が高いです。
準備
早速、Reactアプリケーションの下準備をしていきます。
// Reactアプリケーションを作成
npx create-react-app sample-with-rq --template typescript
cd sample-with-rq
// React Queryをインストール
npm i react-query
まずはApp.tsxを変更して、React Queryを導入します。一番親から、ClientをProvideします。
import React from "react";
import { QueryClient, QueryClientProvider } from "react-query";
const queryClient = new QueryClient();
function App() {
return (
<div>
<QueryClientProvider client={queryClient}>
{/* ここにコンポーネントを足し込んでいく予定 */}
</QueryClientProvider>
</div>
);
}
export default App;
次に、Todoについての処理を書きます。services/todoService.ts
ファイルを作って、そこへ書くことにします。App.tsxに書いてもいいけど...
export type Todo = {
name: string;
status: "Todo" | "Doing" | "Done";
};
let todoList: Todo[] = [
{ name: "My todo", status: "Todo" },
{ name: "Already done", status: "Done" },
];
/* TODOを取得する関数(同期処理だけだが、あえて非同期にする) */
export const getTodoList = async () => {
/** 1秒待つことでReactQueryの挙動を確認する */
await new Promise((resolve) => {
setTimeout(resolve, 1000);
});
return todoList;
};
/* TODOを追加する関数(同期処理だけだが、あえて非同期にする) */
export const addTodo = async (todo: Todo) => {
todoList.push(todo);
return todo;
};
React Queryを試してみる
React Queryを使って、Todoを取得する処理と、Todoを追加する処理を実装していきます。
App.tsxにTodoを取得して一覧表示するTodoListコンポーネントと、新しいTodoを追加するAddTodoコンポーネントを追加します。
import React, { useState } from "react";
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import { addTodo, getTodoList, Todo } from "./services/todoService";
const queryClient = new QueryClient();
function App() {
return (
<div>
<QueryClientProvider client={queryClient}>
<TodoList></TodoList>
<AddTodo></AddTodo>
</QueryClientProvider>
</div>
);
}
/* Todoを取得して一覧表示するコンポーネント */
const TodoList: React.FC = () => {
// useQueryに非同期関数を渡すことで、関数の実行状態を管理したり、実行結果をキャッシュできる
// 第一引数のtodoListはQuery Keyと呼ばれるもので、キャッシュのキーに利用される
const { data, error, isLoading } = useQuery("todoList", getTodoList);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error! {error}</div>;
if (data) {
return (
<>
<h1>Todo List</h1>
{data.map((todo) => (
<div>
<span>{todo.name},</span>
<span>{todo.status}</span>
</div>
))}
</>
);
}
return <></>;
};
/* 新しいTodoを追加するコンポーネント */
const AddTodo: React.FC = () => {
const [todo, setTodo] = useState<Todo>({
name: "",
status: "Doing",
});
const { mutate, isLoading } = useMutation({
mutationFn: (todo: Todo) => {
return addTodo(todo);
},
});
return (
<div>
<h1>New todo</h1>
<input
name="name"
value={todo?.name}
onChange={(e) =>
setTodo((todo) => ({
...todo,
name: e.target.value,
}))
}
></input>
{isLoading ? (
<div>Loading...</div>
) : (
<button
onClick={() => {
todo && mutate(todo);
}}
>
Add
</button>
)}
</div>
);
};
export default App;
ここまでできたら、npm run start
で実行してみます。
getTodoList関数が1秒待機してから結果を返すようにしているので、isLoading
の効果がしっかりと確認できると思います。本題のキャッシュとは別件ですが、便利ですね。
ところで、この状態でテキストボックスに値を入れてAddボタンを押下しても、何も反映されません。
TodoListコンポーネントで取得したTodo一覧は、コンポーネント内部のStateとして管理されており、このStateを更新していないため、画面上に反映されることはありません。
更新が完了したら再度フェッチするという方法がありますが、ここでキャッシュの出番です。
キャッシュを活用して最小限のコストで反映
AddTodoコンポーネントを一部変更します。
/* 新しいTodoを追加するコンポーネント */
const AddTodo: React.FC = () => {
const [todo, setTodo] = useState<Todo>({
name: "",
status: "Doing",
});
+ // useQueryClientを使えば、どこからでもclientにアクセス可能
+ const queryClient = useQueryClient();
const { mutate, isLoading } = useMutation({
mutationFn: (todo: Todo) => {
return addTodo(todo);
},
+ // mutation成功時に実行されるコールバック関数を設定可能
+ // ここで、"todoListが最新じゃなくなった"ことを教える
+ onSuccess: () => {
+ queryClient.invalidateQueries("todoList");
+ },
});
return (
<div>
<h1>New todo</h1>
<input
name="name"
value={todo?.name}
onChange={(e) =>
setTodo((todo) => ({
...todo,
name: e.target.value,
}))
}
></input>
{isLoading ? (
<div>Loading...</div>
) : (
<button
onClick={() => {
todo && mutate(todo);
}}
>
Add
</button>
)}
</div>
);
};
これで、Todoを追加すると、その度にtodoListクエリの実行結果のキャッシュが更新されます。このキャッシュがTodoListコンポーネント内のTodo一覧のStateと連動しています。実行してみましょう。
自動的に反映されることがわかります。
内部の動作を確認してみる
React Queryの魅力のひとつとして、Devtoolsが用意されていることがあります。
Devtoolsを利用して、内部的にどう動いているのかを確認してみます。
まずはDevtoolsが動作するように、コードを一部修正する必要があります。
import React, { useState } from "react";
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from "react-query";
import { addTodo, getTodoList, Todo } from "./services/todoService";
+ // 標準でDevtoolが入っています
+ import { ReactQueryDevtools } from "react-query/devtools";
const queryClient = new QueryClient();
function App() {
return (
<div>
<QueryClientProvider client={queryClient}>
+ // ただ差し込むだけでよい
+ <ReactQueryDevtools />
<TodoList></TodoList>
<AddTodo></AddTodo>
</QueryClientProvider>
</div>
);
}
refetchが走っていることが確認できますね。また、キャッシュの中身も確認できて、とてもわかりやすいです。
まとめ
キャッシュを活用することで、リモートのデータ(今回はダミーですが)の取り扱い方が変容してきているという例でした。データフェッチングライブラリを使うだけで、開発効率がグッと上がりますね。
今回はクエリが正しくなくなったことを通知する形式でしたが、キャッシュを直接書き換える方法もあります。リフェッチによるオーバーヘッドが大きい場合は、そっちの方が良い場合もあるかもしれませんね。