CSSアニメーションが終わるまでトグルを無効化するReactパターン
はじめに
ReactでトグルUIを実装する際、こんな経験はありませんか?
「ボタンを連打したら、CSSアニメーションが中途半端に切り替わって表示が崩れてしまった…」
これは、Reactの状態変更の速さと、CSSアニメーションの再生時間にズレがあるために起こる典型的な問題です。
この記事では、Element.getAnimations() というモダンなWeb APIを使い、CSSアニメーションの完了を正確に検知してトグルの連打を防止する、堅牢な実装パターンを紹介します。
❌ なぜsetTimeoutでは不十分なのか?
従来、アニメーション時間に合わせてsetTimeoutで入力をロックする手法が使われていました。
const [isDisabled, setIsDisabled] = useState(false);
const handleClick = () => {
if (isDisabled) return;
setIsDisabled(true); // ボタンを無効化
setActive(prev => !prev);
// CSSのアニメーション時間(例: 500ms)に合わせてロックを解除
setTimeout(() => {
setIsDisabled(false);
}, 500);
};
この方法は一見シンプルですが、大きな欠点があります。
- 保守性の低下: CSSでアニメーション時間を変更するたびに、JavaScriptのコードも修正する必要がある。
- 不正確さ: 複雑なアニメーションやブラウザの負荷状況によっては、実際の終了時間とズレが生じる可能性がある。
これらの問題を解決するのが、Element.getAnimations() APIです。
解決策:getAnimations() でアニメーションを監視する
Element.getAnimations() は、指定したDOM要素とその子孫要素で 現在実行中のすべてのアニメーション の情報を取得できるWeb APIです。
各アニメーションオブジェクトは .finished というプロパティを持っており、これはアニメーションの完了時に解決される Promise です。
これを利用することで、「すべてのアニメーションが終わるまで待つ」という処理を、setTimeoutに頼らず、正確かつ宣言的に実装できます。
1. useRefでアニメーションの状態を管理する
まず、アニメーション中かどうかを判定するフラグと、アニメーション対象のDOM要素への参照を用意します。
const boxRef = useRef<HTMLDivElement>(null);
const isAnimatingRef = useRef(false);
ここで重要なのは、アニメーションの状態管理にuseStateではなくuseRefを使う点です。
useState: 値が変更されるたびに再レンダリングがトリガーされます。useRef: 値が変更されても再レンダリングは発生しません。
「アニメーション中かどうか」という状態は、UIの見た目に直接影響するものではなく、内部的なロジックのためのフラグです。useRefを使うことで、不要な再レンダリングを防ぎ、パフォーマンスを最適化できます。
2. useEffectでアニメーションの完了を待つ
トグルの状態 active が変更されたことをトリガーに、useEffect を使ってアニメーションの監視を開始します。
useEffect(() => {
const node = boxRef.current;
if (!node) return;
// アニメーション監視用の非同期関数
const checkAnimationEnd = async () => {
// 1. まず、アニメーション中フラグを立ててトグルをロック
isAnimatingRef.current = true;
// 2. 実行中のすべてのアニメーションを取得し、それらの完了Promiseの配列を作成
const animations = node.getAnimations({ subtree: true });
const promises = animations.map(anim => anim.finished);
// 3. すべてのアニメーションが完了するのを待つ
await Promise.all(promises);
// 4. すべて完了したら、フラグを下げてロックを解除
isAnimatingRef.current = false;
};
checkAnimationEnd();
}, [active]); // active(トグルのON/OFF)が変わるたびに実行
このuseEffectが、Reactの 状態(active) と、ブラウザの 外部システム(CSSアニメーション) とを同期させる役割を担います。
3. クリックハンドラでロックを判定
最後に、クリックハンドラでisAnimatingRefフラグをチェックするだけのシンプルな実装です。
const handleToggle = () => {
// isAnimatingRefがtrue(アニメーション中)なら、何もしない
if (isAnimatingRef.current) return;
setActive(prev => !prev);
};
完成形
🚀 【応用】カスタムフックでロジックを再利用可能にする
このアニメーションロックのロジックは、様々なコンポーネントで再利用できます。そこで、useAnimationLockというカスタムフックに切り出してみましょう。
import { useRef, useEffect, RefObject } from 'react';
export function useAnimationLock<T extends HTMLElement>(
targetRef: RefObject<T>,
dependencies: any[]
) {
const isAnimatingRef = useRef(false);
useEffect(() => {
const node = targetRef.current;
if (!node) return;
const checkAnimationEnd = async () => {
isAnimatingRef.current = true;
const animations = node.getAnimations({ subtree: true });
await Promise.all(animations.map(anim => anim.finished));
isAnimatingRef.current = false;
};
checkAnimationEnd();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
return isAnimatingRef;
}
コンポーネント側のコード
カスタムフックを使うことで、コンポーネント本体は非常にシンプルで宣言的になります。
import { useState, useRef } from 'react';
import { useAnimationLock } from './useAnimationLock';
function ToggleBox() {
const [active, setActive] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
// カスタムフックを呼び出すだけ!
const isAnimatingRef = useAnimationLock(boxRef, [active]);
const handleToggle = () => {
if (isAnimatingRef.current) return;
setActive(prev => !prev);
};
return (
<>
<button onClick={handleToggle} disabled={isAnimatingRef.current}>
Toggle
</button>
<div
ref={boxRef}
className={`box ${active ? 'animate-on' : 'animate-off'}`}
/>
</>
);
}
まとめ
ReactとCSSアニメーションを正しく同期させるためのベストプラクティスは以下の通りです。
setTimeoutは使わない:Element.getAnimations()APIを使い、アニメーションの完了を確実に検知する。useRefを賢く使う: UIの再レンダリングが不要な内部的な状態(フラグなど)は、useRefで管理してパフォーマンスを最適化する。- 副作用は
useEffectに集約する: Reactの状態変更をトリガーに外部システム(DOM APIなど)と連携する処理は、useEffectの責務。- カスタムフックでロジックを共通化する: 再利用可能なロジックはカスタムフックに切り出し、コンポーネントをクリーンに保つ。
これらのパターンを活用することで、ユーザー体験を損なわない、堅牢なUIを構築できます。
Discussion