時間ベースキャッシングの落とし穴:フロントエンド状態管理の新しいアプローチ
はじめに
最近LinkedInで「なぜバックエンド開発者はReact Queryを嫌うのか」という記事を読みました。その中で指摘されていた時間ベースのキャッシング戦略の落とし穴について、実際の開発現場でよく遭遇する問題として共感を覚えました。
React Query、SWR、Apollo Clientなど、現代のデータフェッチングライブラリの多くは時間ベースのキャッシング戦略を採用しています。staleTime
やcacheTime
といった設定で簡単にキャッシュを管理できるように見えますが、実はこのアプローチには予想以上に多くの問題が潜んでいます。
時間ベースキャッシングとは
時間ベースキャッシングは、データの有効期限を時間で管理する方式です。データに「賞味期限」をつけるようなものと考えると理解しやすいでしょう:
const { data } = useQuery(['user', userId], fetchUser, {
staleTime: 5 * 60 * 1000, // 5分間は「新鮮」とみなす
cacheTime: 10 * 60 * 1000, // 10分間キャッシュに保持
refetchInterval: 30 * 1000 // 30秒ごとに自動更新
});
一見シンプルで便利そうに見えますが、実際のアプリケーションでは予期せぬ問題を引き起こすことがあります。
時間ベースキャッシングの4つの問題点
1. 予測不可能なUI更新
最も深刻な問題は、ユーザーが操作していないにも関わらず、突然画面の内容が変更されることです。
実際のシナリオ:
09:00:00 - ユーザーAが投稿リストを表示
09:02:00 - ユーザーBが自分のプロフィール名を変更
09:04:59 - ユーザーAの画面には古い名前が表示されている
09:05:00 - staleTime期限切れ、バックグラウンドで再取得
09:05:01 - 突然、画面上の名前が変更される
ユーザーA:「今、何か画面が変わったような...?」
2. データ一貫性の崩壊
同じデータが異なるタイミングで取得されると、画面内で不整合が発生します:
// 投稿リスト(09:00に取得)
const posts = [
{ id: 1, author: { name: "ユーザーX", avatar: "old.jpg" } }
];
// ユーザー詳細(09:03に取得 - プロフィール更新後)
const userDetail = {
name: "ユーザーX",
avatar: "new.jpg" // アバターが更新されている
};
// 結果:同じ画面に異なるアバターが表示される
3. 過剰なAPIリクエスト
時間ベースの自動更新は、データの変更有無に関わらず、不必要なリクエストを大量に発生させます:
// 問題:すべてのデータを同じ間隔で更新
const { data: user } = useQuery(['user'], fetchUser, {
refetchInterval: 30 * 1000 // 30秒ごと
});
const { data: posts } = useQuery(['posts'], fetchPosts, {
refetchInterval: 30 * 1000 // 30秒ごと
});
// 結果:30秒ごとに複数のAPIが同時に呼ばれる
4. キャッシュ無効化の複雑性
データが更新された際、関連するすべてのキャッシュを手動で無効化する必要があり、これは非常に煩雑な作業になります:
const updateUserProfile = useMutation({
mutationFn: updateProfile,
onSuccess: (updatedUser) => {
// どのクエリにこのユーザーが含まれているか?
queryClient.invalidateQueries(['posts']); // 投稿の作成者?
queryClient.invalidateQueries(['comments']); // コメントの作成者?
queryClient.invalidateQueries(['followers']); // フォロワーリスト?
queryClient.invalidateQueries(['messages']); // メッセージの送信者?
// ...無限に続く
}
});
根本的な解決策:モデル単位のキャッシュ管理
時間ベースキャッシングの問題を根本的に解決するには、APIレスポンス単位ではなく、モデル単位でキャッシュを管理する必要があります。
API応答単位キャッシュの問題
React Queryなどのライブラリは、API応答をそのままキャッシュします:
// メッセージAPIの応答をそのまま保存
{
id: "msg1",
content: "Hello",
writer: {
id: "user1",
name: "太郎",
avatar: "old.jpg" // ユーザーがプロフィール画像を変更しても更新されない
}
}
この方式では、同じユーザー情報が複数のAPIレスポンスに含まれ、データの不整合が発生します。
モデル単位キャッシュの仕組み
// モデル単位のキャッシュストア
class ModelCache {
private users = new Map<string, User>();
private posts = new Map<string, Post>();
// APIレスポンスからモデルを抽出して保存
updateFromResponse(response: any) {
// ユーザー情報を正規化して保存
if (response.user) {
this.users.set(response.user.id, response.user);
}
// 投稿に含まれるユーザー情報も抽出
if (response.posts) {
response.posts.forEach(post => {
if (post.author) {
this.users.set(post.author.id, post.author);
}
});
}
}
// モデルの更新は全体に反映される
updateUser(userId: string, updates: Partial<User>) {
const user = this.users.get(userId);
if (user) {
this.users.set(userId, { ...user, ...updates });
// すべての参照が自動的に更新される
}
}
}
GraphQLのApollo Clientは、まさにこのアプローチを採用しています。
まとめ
時間ベースのキャッシングは実装が簡単ですが、データの一貫性やユーザー体験の面で深刻な問題を抱えています。
真に効率的なキャッシュ管理には、APIレスポンス単位ではなくモデル単位でのキャッシュ管理が必要です。GraphQLやApollo Clientが示すように、正規化されたキャッシュこそが、複雑なアプリケーションにおけるデータ管理の正しい方向性と言えるでしょう。
Discussion