React

ReactでGSAPを使う方法|useGSAP・contextSafeとStrictModeの後片付け

ReactのGSAPをscope・dependencies・revertOnUpdateで作成・破棄する実例。contextSafeとイベント解除の違いを整理し、StrictMode、props更新、ScrollTrigger、取り外し後の残存を2枚のカードで確認します。

この記事の目次
  1. useGSAPは、StrictModeの再実行を1回にするフックではない
  2. 2枚のカードを個別に動かすコンポーネントを作る
  3. dependenciesとrevertOnUpdateをセットで考える
  4. contextSafeとイベント解除は、別の役割を持つ
  5. 表示切替・距離変更・スクロールで後片付けを確かめる
  6. ScrollTriggerの位置がずれる場合
  7. Next.js App RouterではClient境界の内側へ置く

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だけの距離変更や表示切替を試してください。

  1. Aの「飾りを回す」を押す。Aだけが回り、Bには影響しないことを確認する。
  2. Aの移動距離を何回か変更する。新しい移動量になり、古いScrollTriggerが増えていかないことを確認する。
  3. Aを非表示にする。Aの処理がなくなり、Bは引き続き動くことを確認する。
  4. Aの表示・非表示を繰り返す。戻るたびに重くなったり、動きが重なったりしないかを見る。
  5. ブラウザの動きを減らす設定を変える。装飾が静止し、カードの内容が読めることを確認する。

この例では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の基本を参照してください。

スポンサーリンク