パフォーマンスを意識しながらOptimistic Updatesないいねボタンを作ろう
はじめに
Zenn や Twitter(X)のような、いいねを押すと即座に UI が反映されるいいねボタン ❤️ を無駄なリクエストを無くしながら実装していきたいと思います。
サーバー側の処理は、簡易的に Prisma と Next.js Route Handlers を使用しています。GitHub に完成コードを置いているので、興味があれば見てみてください。
GitHub と完成コード
完成コード(処理のみを抜粋しています)
export function useLikeButton({ initialLiked }: UseLikeButtonProps) {
// UIに表示する状態
const [currentLikeState, setCurrentLikeState] = useState(initialLiked);
// レンダリングには表示しないが、実際の状態を保持する
const realityLiked = useRef(initialLiked);
const { isPending, mutateAsync } = useMutation({
mutationFn: fetchLike,
onSuccess: ({ liked }) => {
// 実際の状態に強制的に変更する
realityLiked.current = liked;
setCurrentLikeState(liked);
},
onError: () => {
// エラーが発生した場合は元の状態に戻す
setCurrentLikeState(realityLiked.current);
},
});
// 連打対策にdebounceを使用する
const onDebounceLike = useDebouncedCallback(async () => {
// 送信中であれば何もしない
if (isPending) return;
// 現在の状態と実際の状態が同じであれば何もしない
if (realityLiked.current === currentLikeState) return;
const trigger = currentLikeState ? 'like' : 'unlike';
// 適切なpostIdを指定するように修正してください。
await mutateAsync({ postId: 1, trigger });
// その他の処理...
}, 500);
const handleLike = () => {
// UIの状態を変更する
setCurrentLikeState((prev) => !prev);
// 設定したms後までに再度呼び出されなければ実行される
onDebounceLike();
};
return {
currentLikeState,
handleLike,
};
}
使用しているライブラリ
Optimistic Updates とは?
通信のレスポンスを待たずに、ユーザーの操作に対して即座に UI を更新する手法です。
以下の記事に詳しく書かれていました。
考慮しなければならないこと
1. 連打をされた際に不要なリクエストを防ぐ
いいねボタンが連打されると、その回数分だけサーバーへのリクエストが送られます。これはサーバーへの不必要な負荷となるため、避けたい状況です。Optimistic Updates を用いることで、ユーザー操作による UI の即時更新は可能ですが、同時に連打による過剰なリクエストを防止する工夫が求められます。
2. サーバー側とフロント側での状態の同期
いいねの操作後、サーバーとフロントエンドの状態が不一致であると、エラーやユーザーの混乱を引き起こす可能性があります。
そのため、サーバーとフロントエンドの状態を同期させる必要があります。
3. エラーが発生した場合の処理
サーバー側でエラーが発生した際には、フロントエンドの状態を以前の状態に戻す処理が必要です。
これらのことを考慮しながら、いいねボタンを作っていきます。
実装
完成コード(処理のみを抜粋しています)
export function useLikeButton({ initialLiked }: UseLikeButtonProps) {
// UIに表示する状態
const [currentLikeState, setCurrentLikeState] = useState(initialLiked);
// レンダリングには表示しないが、実際の状態を保持する
const realityLiked = useRef(initialLiked);
const { isPending, mutateAsync } = useMutation({
mutationFn: fetchLike,
onSuccess: ({ liked }) => {
// 実際の状態に強制的に変更する
realityLiked.current = liked;
setCurrentLikeState(liked);
},
onError: () => {
// エラーが発生した場合は元の状態に戻す
setCurrentLikeState(realityLiked.current);
},
});
// 連打対策にdebounceを使用する
const onDebounceLike = useDebouncedCallback(async () => {
// 送信中であれば何もしない
if (isPending) return;
// 現在の状態と実際の状態が同じであれば何もしない
if (realityLiked.current === currentLikeState) return;
const trigger = currentLikeState ? 'like' : 'unlike';
// 適切なpostIdを指定するように修正してください。
await mutateAsync({ postId: 1, trigger });
// その他の処理...
}, 500);
const handleLike = () => {
// UIの状態を変更する
setCurrentLikeState((prev) => !prev);
// 設定したms後までに再度呼び出されなければ実行される
onDebounceLike();
};
return {
currentLikeState,
handleLike,
};
}
1. UI に表示する状態と実際の状態を分ける
// UIに表示する状態
const [currentLikeState, setCurrentLikeState] = useState(initialLiked);
// レンダリングには表示しない、対象にいいねをしてあるかどうかの状態
const realityLiked = useRef(initialLiked);
UI に表示する状態はuseState
で管理し、実際の状態はuseRef
で管理します。
useState
で管理している状態 に関しては、実際にいいねされているかどうか関係なく、見た目だけを変更すると思ってください。
2つの状態に分けることで、UI に表示する状態は即座に反映させ、データを送信するかどうかは実際の状態 useRef
と比較することで判断できます。
useRef
の値に関しては、いいねが押されたときの処理で使用します。
つまり、useState
は見た目だけの状態、useRef
は実際のいいねの状態を管理すると考えてください。
別々で状態を管理する理由は以下の実際の値と UI に表示する値を比較する理由で説明します。
2. useDebounce を使用して不要なリクエストを防ぐ
const onDebounceLike = useDebouncedCallback(async () => {
// クリック後500ms 後に実行される処理
}, 500);
useDebouncedCallback
を使用して、連打された際の過剰なリクエストを防ぎます。この場合は、500ms 以内に再度呼ばれた場合は、最後に呼ばれたものだけが実行されます。
3. いいねボタンが押されたときの処理
const onDebounceLike = useDebouncedCallback(async () => {
// 送信中であれば何もしない
if (isPending) return;
// 現在の状態と実際の状態が同じであれば何もしない
if (realityLiked.current === currentLikeState) return;
const trigger = currentLikeState ? 'like' : 'unlike';
// 適切なpostIdを指定するように修正してください。
await mutateAsync({ postId: 1, trigger });
// その他の処理...
}, 500);
まず、Tanstack Query
のisPending
を使用して、送信中であれば何もしないようにします。
次に、ユーザーに表示されている状態が、実際の状態と同じであれば何もしないようにします。
最後に、mutateAsync
を使用して、データを送信します。
4. データ送信後の処理
const { isPending, mutateAsync } = useMutation({
mutationFn: fetchLike,
onSuccess: ({ liked }) => {
// 実際の状態に強制的に変更する
realityLiked.current = liked;
setCurrentLikeState(liked);
},
onError: () => {
// エラーが発生した場合は元の状態に戻す
setCurrentLikeState(realityLiked.current);
},
});
onSuccess
では、useRef
とuseState
の値をサーバー側の値と同期させます。
onError
では、エラーが発生した場合に、useState
の値をuseRef
の前の状態に戻します。
実際の値と UI に表示する値を比較する理由
値の比較を行わずuseState
のみで状態を管理した場合、以下のように動作しないパターンが生じ得ます。
初期状態として、いいねが既にされている状態 ❤️ を想定します。
-
動作しないパターン
- ユーザーがいいねを解除します(ハートがグレーになります ♡)。
- 500ms 経過後にいいねを押す(ハートがピンクになる ❤️)
- さらに 500ms 以内にいいねを解除します(ハートがグレーになります ♡)。
この場合、実行されるのは 1 回目のいいね解除と 3 回目のいいね解除の処理です。
サーバー側から見ると、1 回目でいいねを解除したにも関わらず、次のリクエストで再びいいね解除のリクエストが送られてきます。いいねされていない状態で、いいね解除のリクエストが送られた場合、削除対象のデータが存在しないため、エラーが生じます。反対に、いいねの処理が 2 回実行された場合にも、同一のいいねのデータが重複してしまい、エラーになる可能性があります。
useRef
の値は、1 回目のいいね解除の後に更新されるため、3 回目の UI の状態と実際の状態が同じであるため、リクエストが送信されません。
useRef
がない場合、送信前の状態がわからないため、同じ操作のリクエストが送信されてしまいます。このような問題を回避するために、
useRef
を使用して、実際の状態を管理し、useState
を使用して、UI に表示する状態を管理します。なぜ useState じゃなくて useRef を使うのか?
再レンダリングを行う必要がないためです。
ユーザーに表示するいいねの状態は即座に反映させたいので、useState
を使用します。
一方で、実際にいいねをされているかどうかの状態(UI ではなくデータベースなどに格納されている状態)は、再レンダリングを行う必要がないため、useRef
を使用します。
まとめ
useRef
を使用して、実際の状態を管理し、useState
を使用して、UI に表示する状態を管理することで、いいねボタンを作成しました。
いいねボタン以外にもフォローボタンなども同じように実装することができます。
さいごに
もし、間違いや改善点があれば、コメントで教えていただけると嬉しいです。
無限スクロール + 検索機能付きの Select Box を作成した記事も書いていますので、興味があれば見てみてください。
ありがとうございました。
Discussion