
はじめに
お気に入り(いいね・ブックマーク)ボタンは、追加だけを実装して「解除」の導線を作り忘れやすい部品です。
一度オンにするとボタンが押せなくなり、取り消せないまま残ってしまいます。
実運用しているサービスで、まさにこの不具合を直しました。
原因は「オンなら disabled」という設計と、解除に必要なレコードIDをどこにも保持していなかったことでした。
さらに、対象データにバージョン切り替えがあると、前のバージョンの状態やIDを引き継ぐ回帰バグも起こります。
この記事では、トグル化の実装と、つまずいた箇所の切り分け方をまとめます。
こんな人におすすめ
- お気に入りやいいねのボタンを、追加専用から解除可能なトグルに直したい方
- 非同期処理中の連打や再入をどう扱うか迷っている方
- 編集履歴やリビジョンなど、バージョンごとに状態を持つUIを作っている方
- カスタムフックをモックしたテストで回帰を見逃した経験がある方
「オンなら disabled」が解除手段を奪う
見た目としては、追加済みのボタンを無効化するのは自然です。
ただしそれは「オン→オフ」の経路を設計から消すことでもあります。
トグルを作るときは、双方向の経路を最初から用意します。
解除にはレコードID(favoriteId のようなもの)が必要になることが多いです。
追加に成功した時点で返ってきたIDを保持していないと、後から解除する手段がありません。
そこで、追加成功時にIDを状態へ保存し、コールバックにも渡す形にします。
const handleFavorite = useCallback(async () => {
if (isProcessing) return; // 再入防止ガード
setIsProcessing(true);
try {
if (isActive) {
if (!recordId) {
// 解除に必要なIDが取得できていない場合は解除できない
onToastMessage?.("解除に失敗しました");
return;
}
const result = await removeItem(recordId, session);
if (result.success) {
setIsActive(false);
setRecordId(null);
onRemoved?.();
} else {
onToastMessage?.(result.message || "解除に失敗しました");
}
return;
}
const result = await addItem(target, session);
if (result.success) {
setIsActive(true);
setRecordId(result.item?.id ?? null);
onAddSuccess?.(result.item?.id); // 成功時のIDを渡す
} else {
onToastMessage?.(result.message || "追加に失敗しました");
}
} finally {
setIsProcessing(false);
}
}, [isProcessing, isActive, recordId /* ... */]);
isActive で追加と解除を分岐し、解除には保存しておいた recordId を使います。
処理中に反対方向の操作が来た場合は、常に即 return する方針にしました。
この挙動は専用のテストで固定しておくと、後の変更で崩れにくくなります。
失敗を無言にしない
削除APIの戻り値を確認していないと、失敗してもUIは何も変わりません。
非同期の副作用を持つ関数は、戻り値のハンドリングを省くと気づかれにくい不具合になります。
成功・失敗を boolean で返し、失敗時はエラーを表示します。
const handleDelete = async (id: ItemId): Promise<boolean> => {
const ok = await remove(id);
if (ok) {
setDeleteError(null);
} else {
setDeleteError("削除に失敗しました。時間をおいて再度お試しください。");
}
return ok;
};
バージョン切り替えと状態同期
対象データにバージョンがある場合、お気に入り状態はバージョンごとに追跡する必要があります。
追跡しないと、切り替えた直後に前バージョンの状態やIDを引き継いでしまいます。
useEffect で同期するときに起きがちなのが、「初回に1回だけやる処理」と「切り替えのたびにやる処理」を同じ条件で扱ってしまうことです。
次のコードでは、「まだ1件も追跡していない」という条件を足して、初回シードと通常の切り替えを分けています。
useEffect(() => {
if (!hasVersions || !activeVersionId) return;
// シードは「まだどのバージョンも追跡していない」場合の1回だけにする。
// この条件がないと、切替直後に前バージョンの状態を
// 新バージョンのものとして取り込んでしまう。
if (
trackedVersionIds.size === 0 &&
isActive &&
!trackedVersionIds.has(activeVersionId)
) {
setTrackedVersionIds((prev) => new Set(prev).add(activeVersionId));
return;
}
setIsActive(trackedVersionIds.has(activeVersionId));
setRecordId(recordIdsByVersion.get(activeVersionId) ?? null);
}, [hasVersions, activeVersionId, trackedVersionIds, recordIdsByVersion, isActive]);
モックしたテストでは回帰を拾えない
既存のユニットテストは、対象のカスタムフックを丸ごとモックしていました。
そのため、フック内部の状態管理ロジックを消してもテストが通ってしまいます。
フックを実体のまま動かすテストを別に用意すると、このタイプの回帰を検知できます。
import { renderHook, act } from "@testing-library/react";
it("バージョンを切り替えても前バージョンのIDを引き継がない", async () => {
const { result, rerender } = renderHook(
({ versionId }) => useFavorite({ target, versionId }),
{ initialProps: { versionId: "v1" } }
);
await act(async () => {
await result.current.toggle(); // v1 をお気に入りに追加
});
expect(result.current.isActive).toBe(true);
rerender({ versionId: "v2" });
expect(result.current.isActive).toBe(false);
expect(result.current.recordId).toBeNull();
});
上のコードは考え方を示す例です。フック名や引数は、ご自身のコードに合わせて読み替えてください。
つまづきやすいポイント
- 保存先がブラウザ内ストレージだとネットワークログに何も出ません。 今回はIndexedDBに保存していたため、通信ログだけでは原因が分からず、コードを読んで特定しました
- 制御文字を区切りに使うとレビューできなくなります。 URL結合のセパレータにNUL文字を使っていた実装があり、diffツールがファイル全体をバイナリ差分として扱ってしまいました。目に見えない文字は区切りに使わない方が安全です
recordIdがnullの状態で解除に進む経路を忘れがちです。 リロード後などにIDが復元できないケースがあるため、その場合のメッセージと挙動も決めておきます- 処理中ガードはトグル化で意味が変わります。 追加専用なら自明ですが、反対方向の操作が来たときの扱いまで決めておく必要があります
まとめ
トグルボタンは「オフ→オン」だけでなく「オン→オフ」の経路も必ず設計します。
解除に必要なIDは、追加成功時に保持して後続の処理へ渡します。
バージョン切り替えがある場合は、初回シードと通常の同期を条件で分けます。
フックをモックしたテストだけに頼らず、実体で動かすテストも併せて用意すると、回帰を拾いやすくなります。


