Reactの画面を開き直すたびにアニメーションが重なる。同じclassを付けた別のカードまで動く。ScrollTriggerが画面を離れても残る。こうした問題は、GSAPで作った処理の範囲と、片付けるタイミングをそろえると追いやすくなります。
useGSAPで作成と破棄をまとめ、scopeで対象を絞り、依存値を変えて作り直すならrevertOnUpdateを使います。 クリック後に作るTweenはcontextSafeで登録し、手動で付けたイベントリスナーは自分で解除します。
useGSAPは、StrictModeの再実行を1回にするフックではない
ReactのStrictModeは、開発時にEffectの追加のsetup・cleanupを行い、後片付けの不足を見つけます。次の実例でも、アプリのルートにStrictModeを付けています。
目指すのは、コンソールに出るsetupの回数を減らすことではありません。setup → cleanup → setupのあと、必要なアニメーションだけが残っている状態です。useGSAPは内部のGSAP Contextを使って、登録されたTweenやScrollTriggerを戻します。仕組みはGSAP公式のReactガイドで確認できます。
useRefに「もう実行した」というフラグを入れて2回目を止めるより、作ったものを片付けられるかを確認します。Effect自体の依存配列やStrictModeの基本は、useEffectを初回実行するときの設計で整理しています。
2枚のカードを個別に動かすコンポーネントを作る
実例は、スクロールで動く四角と、クリックで回る飾りを持つカードです。カードAとBは同じclass名を使いますが、それぞれのrootの内側だけをGSAPの対象にします。
MotionCard.jsx
import React, { useRef, useSyncExternalStore } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { useGSAP } from '@gsap/react';
gsap.registerPlugin(useGSAP, ScrollTrigger);
const motionQuery = '(prefers-reduced-motion: reduce)';
const getMotion = () => window.matchMedia(motionQuery).matches;
const getServerMotion = () => true;
function subscribeMotion(notify) {
const media = window.matchMedia(motionQuery);
media.addEventListener('change', notify);
return () => media.removeEventListener('change', notify);
}
export default function MotionCard({ name, distance }) {
const root = useRef(null);
const reduceMotion = useSyncExternalStore(
subscribeMotion, getMotion, getServerMotion,
);
useGSAP((context, contextSafe) => {
if (reduceMotion) return;
gsap.from('.badge', { opacity: 0, y: 12, duration: 0.4 });
gsap.to('.box', {
x: distance,
ease: 'none',
scrollTrigger: {
trigger: root.current,
start: 'top 75%',
end: 'bottom 25%',
scrub: true,
},
});
const button = root.current.querySelector('.turn');
const turn = contextSafe(() => {
gsap.to('.badge', {
rotation: '+=90', duration: 0.5, overwrite: 'auto',
});
});
button.addEventListener('click', turn);
return () => button.removeEventListener('click', turn);
}, {
scope: root,
dependencies: [distance, reduceMotion],
revertOnUpdate: true,
});
return (
<section className="card" ref={root} data-card={name}>
<h2>カード {name}</h2>
<p>移動距離:{distance}px</p>
<div className="box" aria-hidden="true" />
<div className="badge" aria-hidden="true">+</div>
<button className="turn" type="button" disabled={reduceMotion}>
飾りを回す
</button>
{reduceMotion && <p>動きを減らす設定に合わせ、装飾を静止しています。</p>}
</section>
);
}
scope: root は、GSAPへ渡した '.box' や '.badge' を、そのrootの子孫へ絞ります。JavaScript全体の document.querySelector を書き換える機能ではないので、イベントを付けるbuttonは root.current.querySelector で取得しています。
useSyncExternalStore の部分は、ブラウザの「動きを減らす」設定をReactへ渡しています。設定が変わるとreduceMotionも変わり、古いアニメーションを片付けて静止状態へ戻ります。表示内容はCSSで隠していないため、装飾を省略してもカードの情報は読めます。
dependenciesとrevertOnUpdateをセットで考える
この例の移動量はpropsのdistanceで変わります。依存配列へdistanceを入れ、revertOnUpdate: true にすることで、変更前のTween・ScrollTriggerと返したcleanupを処理してから、新しい距離で作り直します。
useGSAPは、依存配列を指定しただけでは更新のたびにすべてをrevertする設定になりません。更新時にも以前の構成を戻したい場合の指定がrevertOnUpdateです。設定の違いは@gsap/reactの公式READMEに説明があります。
この方法では、距離の更新時に登場アニメーションも作り直します。登場はマウント時だけ、移動量は後から更新したい、といった要件なら、その2つを別のuseGSAPへ分けます。再実行の単位は、同じタイミングで作り直したい処理でまとめてください。
contextSafeとイベント解除は、別の役割を持つ
クリックの時点はuseGSAPの最初の実行より後です。そのとき新しく作るTweenも後で片付けられるよう、例ではturnを contextSafe で包んでいます。これでコールバック実行中に作られたGSAPの処理がContextへ登録されます。
ただし、contextSafeはaddEventListenerを自動解除する機能ではありません。 buttonとturnの参照を保存し、返すcleanupで同じ組み合わせをremoveEventListenerへ渡しています。取り外したDOMのボタンから、新しいアニメーションが始まる経路もここで消します。
| 作ったもの | 片付け方 |
|---|---|
| useGSAPの実行中に作るTween・ScrollTrigger | useGSAPのContextでrevert |
| クリック後に新しく作るTween | contextSafeで登録し、Contextでrevert |
| addEventListenerで登録する処理 | cleanupでremoveEventListener |
| setTimeout・setInterval | cleanupでclearTimeout・clearInterval。後から作るGSAP処理の登録も別に考える |
通常のReactのボタンならJSXのonClickも使えます。その場合も、クリックでGSAPの新しいTweenを作る関数をcontextSafeにする考え方は同じです。今回は、手動で付けたイベントの解除まで確認するため、nativeのリスナーを使っています。
また、contextSafeは非同期処理をキャンセルしません。awaitの後にGSAPの処理を作る場合は、実際に作成する部分を包むことと、画面を離れたあとに呼ばせないことを別に設計します。
表示切替・距離変更・スクロールで後片付けを確かめる
MotionCard.jsxと、次の4ファイルを同じフォルダへ保存します。Node.js 24.13.0、React 19.3.0、GSAP 3.15.0、@gsap/react 2.1.2、Vite 7.3.6で確認した構成です。
package.json
{
"name": "react-gsap-cleanup-example",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 5206 --strictPort",
"build": "vite build"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"gsap": "3.15.0",
"@gsap/react": "2.1.2"
},
"devDependencies": {
"vite": "7.3.6",
"@playwright/test": "1.61.1"
}
}
main.jsx
import React, { StrictMode, useState } from 'react';
import { createRoot } from 'react-dom/client';
import MotionCard from './MotionCard.jsx';
import './style.css';
function App() {
const [visible, setVisible] = useState(true);
const [distance, setDistance] = useState(60);
return (
<main>
<h1>ReactとGSAPの後片付けを確認する</h1>
<div className="controls">
<button onClick={() => setVisible(value => !value)}>Aを表示・非表示</button>
<button onClick={() => setDistance(value => value === 60 ? 120 : 60)}>
Aの移動距離を変更
</button>
</div>
<div className="space" aria-hidden="true" />
{visible && <MotionCard name="A" distance={distance} />}
<MotionCard name="B" distance={80} />
<div className="space" aria-hidden="true" />
</main>
);
}
createRoot(document.getElementById('root')).render(
<StrictMode><App /></StrictMode>,
);
style.css
body { margin: 0; color: #17252b; background: #f3f6f4; font-family: sans-serif; }
main { max-width: 720px; margin: auto; padding: 20px; }
h1 { font-size: 1.5rem; }
button { font: inherit; padding: 10px; cursor: pointer; }
button:disabled { cursor: default; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; }
.space { height: 65vh; }
.card { padding: 24px; margin: 24px 0; border: 1px solid #aac3b8; background: white; }
.box { width: 56px; height: 56px; margin-block: 20px; background: #267a5a; }
.badge { display: grid; place-items: center; width: 40px; height: 40px; margin-block: 20px; background: #e9c969; }
index.html
<!doctype html>
<html lang="ja">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>React GSAP cleanup</title></head>
<body><div id="root"></div><script type="module" src="/main.jsx"></script></body>
</html>
npm install のあと npm run dev を実行し、表示されたローカルURLを開きます。スクロールして四角を動かし、カードAだけの距離変更や表示切替を試してください。
- Aの「飾りを回す」を押す。Aだけが回り、Bには影響しないことを確認する。
- Aの移動距離を何回か変更する。新しい移動量になり、古いScrollTriggerが増えていかないことを確認する。
- Aを非表示にする。Aの処理がなくなり、Bは引き続き動くことを確認する。
- Aの表示・非表示を繰り返す。戻るたびに重くなったり、動きが重なったりしないかを見る。
- ブラウザの動きを減らす設定を変える。装飾が静止し、カードの内容が読めることを確認する。
この例ではChromeの幅390px・1440pxで、StrictModeによる各カードの追加setup、カード間の分離、依存値の3回変更、3回の取り外し・再表示を確認しました。取り外した古いDOMのボタンを呼んでもTweenが増えず、変更したstyleが戻ることも確認しています。
調査中は ScrollTrigger.getAll() で件数やtriggerを確認できます。ただし、実アプリにはほかの画面のScrollTriggerもあります。cleanupで一覧を全件killすると、別のコンポーネントまで壊すので、自分が作ったContextの範囲で片付けます。
ScrollTriggerの位置がずれる場合
後から読み込む画像やフォントでレイアウトが変わると、登録時の位置と実際の位置がずれることがあります。作成・破棄の問題と、レイアウト確定後のrefreshが必要な問題は分けて調べます。依存配列を空にしても、位置が自動で正しくなるわけではありません。
Next.js App RouterではClient境界の内側へ置く
App Routerへ移す場合は、Server Componentから直接読み込むアニメーション用コンポーネントのファイル先頭に 'use client'; を置きます。そこから読み込む子ファイルすべてに付ける必要はありません。Next.js公式のuse clientは、この境界を説明しています。
app/page.jsx Server Component
-> app/AnimatedCards.jsx 先頭に 'use client';
-> MotionCard.jsx useGSAPを使う子コンポーネント
style.cssはアプリ側で読み込む
Vite用のindex.html・createRootは持ち込まない
上の配置はApp Routerへの移し方です。実例の動作確認はViteで行っており、Next.jsでのビルド・hydrationまで検証した構成ではありません。移植時は、そのアプリの画面遷移と再表示も確認してください。
まず自分のコンポーネントにscopeを付け、useGSAPの中で作ったものを列挙してください。次に、クリックやタイマーから後で作るものと、GSAP以外の解除が必要なものを見直します。この順で確認すると、StrictModeを外さずに残存する処理を減らせます。Tween自体の書き方は、GSAPのto・from・fromToの基本を参照してください。