Animate the tag follow toggle while the request settles

The chip toggle swaps its icon for a tiny current-color spinner from
the click until the follows list reflects the new state, covering the
refetch gap so '+' never flashes back before the checkmark.
This commit is contained in:
2026-09-17 14:14:58 -05:00
parent 4585a8ac43
commit c396c368dc
@@ -11,6 +11,8 @@ export function TagFollowToggle({ tag }: { tag: string }) {
const user = useAuth((state) => state.user);
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
// What the chip should show once the request and refetch settle.
const [target, setTarget] = useState<boolean | null>(null);
const query = useQuery({
queryKey: ["follows-tags"],
@@ -37,9 +39,17 @@ export function TagFollowToggle({ tag }: { tag: string }) {
void queryClient.invalidateQueries({ queryKey: ["follows-feed"] });
void queryClient.invalidateQueries({ queryKey: ["follows-cloud"] });
},
onError: (err) => setError(errorMessage(err)),
onError: (err) => {
setTarget(null);
setError(errorMessage(err));
},
});
// Keep the spinner up until the refetched list reflects the new state.
// A stale target that matches `followed` is harmless: the next click
// recomputes it before mutating.
const busy = mutation.isPending || (target !== null && followed !== target);
if (!user) return null;
function handleClick(event: MouseEvent<HTMLButtonElement>) {
@@ -47,6 +57,8 @@ export function TagFollowToggle({ tag }: { tag: string }) {
// action and must not trigger them.
event.preventDefault();
event.stopPropagation();
if (busy) return;
setTarget(!followed);
mutation.mutate();
}
@@ -54,16 +66,21 @@ export function TagFollowToggle({ tag }: { tag: string }) {
<button
type="button"
onClick={handleClick}
disabled={mutation.isPending || query.isPending}
disabled={busy || query.isPending}
title={error ?? (followed ? `Unfollow ${tag}` : `Follow ${tag}`)}
aria-label={followed ? `Unfollow ${tag}` : `Follow ${tag}`}
className={cn(
"-mr-0.5 inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-sm transition",
error ? "text-ctp-red" : "opacity-70 hover:opacity-100",
mutation.isPending && "opacity-40",
query.isPending && "opacity-40",
)}
>
{followed ? (
{busy ? (
<span
aria-hidden="true"
className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-current border-t-transparent"
/>
) : followed ? (
<Check className="h-3 w-3" strokeWidth={3} />
) : (
<Plus className="h-3 w-3" strokeWidth={3} />