ReactでGSAPを利用する

GSAP)は、ブラウザベースのアニメーションライブラリで、React、Vue、Angularなどの様々なフレームワークで使用することができます。
*Next.jsではSSR(サーバーサイドレンダリング)の環境では直接使用することができませんが
クライアントサイド(ブラウザ環境)で実行するコードを定義することができます。

参考ドキュメント

目次
  1. GSAPの導入
  2. useRef(DOM要素への直接的な参照)
  3. ユーザーのインタラクション
  4. useEffectとuseLayoutEffect
  5. gsap.context
    1. gsap.contextについて
    2. クリーンアップについて
  6. コンポーネントの再利用
  7. タイムラインの作成と制御
  8. アニメーションを作成するタイミングの制御
  9. useRef(一貫性を保つべき情報)
  10. ScrollTrigger
  11. 親子間でタイムラインを渡す
  12. Context
  13. forwardRefについて
  14. 再利用可能なアニメーションの作成
  15. GSAPのRegisterEffect()
  16. DOMから要素を削除するアニメーション

GSAPの導入

プロジェクトのルートディレクトリで、GSAPをインストール
GSAPの「コアライブラリ」がインストールされます。「コアライブラリ」には、基本的なアニメーション機能や一部のプラグインが含まれています

npm install gsap

任意のReactコンポーネントからGSAPをインポートできます。
例は.my-elementクラスを持つDOM要素をを右に100ピクセル移動させます。

import { gsap } from "gsap";
import { useEffect} from "react";

export default function MyComponent() {
 useEffect(() => {
    gsap.to(".my-element", { duration: 2, x: 100 });
  }, []);
  return <div className="my-element">Hello, world!</div>;
}

プラグインの利用
一部の特定のプラグイン(例えばScrollTrigger, MorphSVGPlugin, Draggableなど)を使用するには、それらを別々にインポートし、gsap.registerPlugin()メソッドを使用して登録する必要があります
*これらのプラグインはnpm install gsapによってインストールされるパッケージに含まれているので、別途インストールする必要はありません。

import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

useRef(DOM要素への直接的な参照)

GSAPとReactを組み合わせて使用する際には、「useRef」と「useEffect」という2つのReactフックが非常に重要な役割を果たします。
「useEffect」フックを利用すれば、クライアントサイド(ブラウザ環境)で実行するコードを定義することができます

useRefについて

useRefはDOM要素への直接的な参照を保持するために使用されることが多いです。特定のDOM要素に対して何らかの操作(フォーカスの設定、スクロール位置の取得など)を行いたいときに便利です。

useRefとref属性を使用して直接DOM要素への参照を取得できます。

const myRef = useRef(null);
// ...
<div ref={myRef}>Hello World</div>

useRefのcurrentプロパティを使って指定した要素を取得
currentプロパティは、useRefが作成したオブジェクトが保持する値です
*GSAPでアニメーションをターゲットにするDOM要素を指定

import { gsap } from "gsap";
import { useEffect, useRef } from "react";

export default function MyComponent() {
  const myElement = useRef(null);

  useEffect(() => {
    gsap.to(myElement.current, { duration: 2, x: 100 });
  }, []);

  return <div ref={myElement}>Hello, world!</div>;
}

useRefはDOM要素だけでなく、任意の値を保持することが可能です。
コンポーネントのライフサイクル全体で一貫して保持したい値がある場合に有用です。
例えば、タイマーID、外部ライブラリのインスタンス、または頻繁に変更されるが再レンダリングを引き起こすべきではない値などを保持することができます。

ユーザーのインタラクション

Reactでは、ユーザーのインタラクション(クリックやホバーなど)に応じてアニメーションを実行することが容易です。
特定のイベントに対してコールバックを設定し、そのコールバック内でアニメーションを開始できます

マウスが要素に入る(onMouseEnter)ときにボックスが拡大し、マウスが要素から離れる(onMouseLeave)ときにボックスが元のサイズに戻るアニメーションを実装しています
currentTargetは、イベントハンドラが設定されたDOM要素を参照するイベントオブジェクトのプロパティです

const { useEffect, useState } = React;

function App() {
  
  const onEnter = ({ currentTarget }) => {
    gsap.to(currentTarget, { backgroundColor: "#e77614", scale: 1.2 });
  };
  
  const onLeave = ({ currentTarget }) => {
    gsap.to(currentTarget, { backgroundColor: "#28a92b", scale: 1 });
  };
  
  return (
    <div className="app flex-row">
      <div className="box" onMouseEnter={onEnter} onMouseLeave={onLeave}>
        Hover Me
      </div>
    </div>
  );
}

useEffectとuseLayoutEffect

  • useEffect: 非同期に動作し、ブラウザが描画を終えた後に呼び出されます。これは大部分の副作用(データの取得、タイマーの設定、手動でのDOMの変更など)に対して適しています。
    useEffect内で行われる作業はユーザーには見えないため、パフォーマンスに影響を与えることなく重い作業を行うことができます。
  • useLayoutEffect: 同期的に動作し、ブラウザが描画を行う前に呼び出されます。
    これはDOMの変更が即座に反映される必要がある場合や、描画の前に何らかの計算を行う必要がある場合に適しています。
    例えば、アニメーションの開始、スクロール位置の取得、レイアウトの計算などです
    同期的に実行されるため、重い処理を行うとブラウザの描画をブロックし、パフォーマンスに影響を与える可能性があります。

useEffectをデフォルトとして使用し、特定の問題(ちらつきやレイアウトのジャンク)が発生した場合にuseLayoutEffectに切り替えてみる。

useLayoutEffectはReactがすべてのDOM変更を行った直後に実行されます

const comp = useRef(); 

useLayoutEffect(() => {
  
  // アニメーションのコード
  
  return () => { 
    // クリーンアップコードを実行するための関数を返します
  }
  
}, []); // 空の依存関係配列なので、レンダリングごとに再実行されません

gsap.context

gsap.contextについて

gsap.context()は、いつでもアニメーションやスクロールのトリガーを管理できるようにする特別な機能です。これにより、必要ならすべてを一度に元の状態に戻すことができます。
また、セレクタを特定の要素やRefに束縛することができるため、コードをシンプルにすることが可能です。

gsap.context()のメソッド

  • add():新しいアニメーションやエフェクトをコンテキストに追加するためのメソッドです。これにより、後からイベントハンドラ(例えばマウスクリック)を設定し、それに応じて新たなアニメーションを作成してコンテキストに追加することが可能です。
  • revert():コンテキストに追加されたすべてのアニメーションとスクロールトリガーを一括して元の状態に戻します。これにより、大量のアニメーションを効率的に管理することが可能です。

gsap.context()を使って特定の要素(ref)を指定すると、その要素の下位のDOMツリーに対してセレクタを適用できます

// ルートレベルの要素のrefを作成します(スコープのため)
const comp = useRef(); 

// アニメーションを適用する要素のrefを作成します
const circle = useRef();

// useLayoutEffectフックを使用します
useLayoutEffect(() => {
  
// GSAPのcontextを作成します。この関数は即時に実行され、
//この関数の実行中に作成された全てのGSAPアニメーションとScrollTriggersが記録されます。
//これにより、後でrevert()を使ってクリーンアップすることができます
  let ctx = gsap.context(() => {
    
    // アニメーションではセレクタテキスト(例:".box")を使用できます
    // これは、コンポーネントの子要素である'.box'要素のみを選択します
    gsap.to(".box", {...});
    
    // または、refsを使用することもできます
    gsap.to(circle.current, { rotation: 360 });
    
  }, comp); //  これにより、セレクタテキストがスコープ内でのみ有効になります
  
  // クリーンアップのために、return文でrevert()を呼び出します
  return () => ctx.revert(); 
  
}, []); //useLayoutEffectが再実行されるのを防ぎます

クリーンアップについて

クリーンアップが必要な理由について

  • 存在しないDOM要素を参照するエラー: コンポーネントがアンマウントされた後もアニメーションが進行していると、そのアニメーションは存在しないDOM要素(アンマウントされたコンポーネントに関連付けられた要素)を参照しようとします。これはエラーを引き起こし、アプリケーションの動作に問題を生じさせる可能性があります
  • メモリリーク: アニメーションがクリーンアップされずに残っていると、そのアニメーションが占有していたメモリが解放されず、他のプロセスによって再利用できなくなります。これはメモリリークと呼ばれ、時間とともにアプリケーションのパフォーマンスを低下させる可能性があります。
  • 非React管理のDOMへの参照: Reactが管理していないDOM要素に対してuseRefを使い、その参照を永続化してしまうと、そのDOM要素はメモリ上に残り続けてしまいます。
  • 非同期操作: setTimeout、setInterval、非同期API呼び出しなどで生成された非同期のコールバック関数内でrefを使う場合は、そのrefがまだ存在するかどうかを確認することが重要です。そうしないと、コンポーネントがアンマウントされた後もrefが生きている可能性があります。
  • イベントリスナー: 外部ライブラリや手動で追加したイベントリスナーがrefを参照している場合、そのリスナーが削除されていない限りrefもメモリに残り続けます。

gsap.context()を使用して、.box1と.box2のアニメーションを一度にクリーンアップしています

useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    const animation1 = gsap.to(".box1", { rotation: "+=360" });

    const animation2 = gsap.to(".box2", {
      scrollTrigger: {
        //...
      }
    });
  }, el);

  const onMove = () => {
    //...
  };
  window.addEventListener("pointermove", onMove);

  return () => {
    ctx.revert(); //アニメーションとScrollTriggersを一度にクリーンアップ(削除)

    window.removeEventListener("pointermove", onMove); // イベントリスナーを削除する
  };
}, []);

コンポーネントの再利用

「props」を子に渡してクラス名データ属性を調整し、特定の要素をターゲットにすることができます
*React はスタイリングのためにはクラスを使用し、アニメーションなどの JS機能の要素にはデータ属性を使用することを推奨しています

同じコンポーネントを異なるプロパティで複数回使用
Boxコンポーネントを3つ使用していますが、それぞれに異なるプロパティを渡しています。
1つ目のBoxにはanim=”rotate”を、
2つ目のBoxにはclassName=”dont-animate”を、
3つ目のBoxにはanim=”move”を渡しています。
これにより、それぞれのBoxコンポーネントは異なるアニメーションを持つことができます。

const { useLayoutEffect, useRef } = React;

const Box = ({ children, className, anim }) => {
  return <div className={"box " + className } data-animate={ anim }>{children}</div>;
};

function App() {
  const app = useRef();
  
  useLayoutEffect(() => {
    
    const ctx = gsap.context(() => {
      gsap.to("[data-animate='rotate']", { //指定した要素に対してアニメーションを適用
        rotation: 360,
        repeat: -1,
        repeatDelay: 1,
        yoyo: true
      });
      
      gsap.to("[data-animate='move']", { //指定した要素に対してアニメーションを適用
        x: 100,
        repeat: -1,
        repeatDelay: 1,
        yoyo: true
      });
      
      gsap.set(".dont-animate", { // 指定した要素のスタイルを設定
        backgroundColor: 'red'
      });
      
    }, app);// スコープ
    
    return () => ctx.revert();
  }, []);
  
  return (
    <div className="app" ref={app}>
      <Box anim="rotate">Box</Box>
      <Box className="dont-animate">Don't Animate Me</Box>
      <Box anim="move">Box</Box>
    </div>
  );
}

備考:useLayoutEffectフック内
gsap.toは、指定した要素に対してアニメーションを適用
gsap.setは、指定した要素のスタイルを設定

タイムラインの作成と制御

タイムラインは複数のアニメーションを順番に制御する場合に便利です。Reactコンポーネント内でタイムラインを作成するときは、useRefフックを使ってタイムラインインスタンスを保持するのが推奨されます。useRefはコンポーネントのライフサイクル全体を通じてインスタンスを維持し、再レンダリングのたびに新しいタイムラインが作成されるのを防ぎます。

一方、関数の外部(トップレベル)でタイムラインを定義すると、このタイムラインはすべてのコンポーネントインスタンスで共有されます。これは複数のインスタンスが同じタイムラインを使用する場合、問題を引き起こす可能性があります。また、コンポーネントがアンマウントされたときにタイムラインのクリーンアップができないため、メモリリークを引き起こす可能性もあります。

タイムラインは、複数のアニメーションを一元管理し、同時に再生、一時停止、逆再生、スキップする能力を提供します。しかし、毎回のレンダリングで新しいタイムラインが作成されると、パフォーマンス問題や予期しない挙動を引き起こす可能性があります。そのため、タイムラインは一度だけ作成し、その参照を保持するためにReactのrefが使用されます

useLayoutEffectフック内でタイムラインが作成され、その参照がtlというrefに保存されます。
これにより、コンポーネントが再レンダリングされてもタイムラインは再作成されず、同じタイムラインを再利用できます

function Box({ children }) {
  return <div className="box">{children}</div>;
}
function Circle({ children }) {
  return <div className="circle">{children}</div>;
}
function App() {
  const el = useRef();
  const tl = useRef();

  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      tl.current = gsap
        .timeline()
        .to(".box", {
          rotate: 360
        })
        .to(".circle", {
          x: 100
        });
    }, el);
  }, []);

  return (
    <div ref={el}>
      <Box>Box</Box>
      <Circle>Circle</Circle>
    </div>
  );
}

タイムラインの制御
「useState」と「useEffect」を使用してタイムラインの再生方向を切り替える機能を追加しています

function App() {
//reversedという状態を作成し、それが変更されるたびにuseEffectフックが実行
//、
  const [reversed, setReversed] = useState(false);
  const app = useRef();
  const tl = useRef();
      
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      tl.current = gsap.timeline()
        .to(".box", {
          rotation: 360
        })
        .to(".circle", {
          x: 100
        });
    }, app);
    return () => ctx.revert();
  }, []);
 
//これによりタイムラインの再生方向が切り替わります 
//reversedの値がtrueになるとタイムラインは逆方向に、falseになると正方向に再生
//ボタンをクリックすることで切り替える
  useEffect(() => {
    tl.current.reversed(reversed);    
  }, [reversed]);
   
  return (
    <div ref={app}>
      <div>
        <button onClick={() => setReversed(!reversed)}>Toggle</button>
      </div>
      <Box>box</Box>
      <Circle>circle</Circle>
    </div>
  );
}

アニメーションを作成するタイミングの制御

依存配列が空の場合([])、最初のレンダリング後に一度だけ実行されます

useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    gsap.to(".box-1", { rotation: "+=360" });
  }, el);
}, []); 

依存配列が指定されていない場合、コンポーネントがレンダリングされるたびに実行されます

useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    gsap.to(".box-3", { rotation: "+=360" });
  }, el);
}); 

特定のプロパティやステートの変更に応じて何かを行いたい場合
依存配列に特定の値(例えばsomeProp)が含まれている場合、その値が変更される場合に実行されます

useLayoutEffect(() => {
  const ctx = gsap.context(() => {
    gsap.to(".box-2", { rotation: "+=360" });
  }, el);
}, [someProp]);

依存配列に特定の値が含まれている場合
これは、親コンポーネントから子コンポーネントにプロパティを渡すときに便利です。
BoxコンポーネントがendXというプロパティを受け取り、そのプロパティが変更されるたびにアニメーションが再実行されます

function Box({ children, endX }) {
  const boxRef = useRef();

  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      gsap.to(boxRef.current, {
        x: endX
      });
    });
    return () => ctx.revert();
  }, [endX]);

  return (
    <div className="box" ref={boxRef}>
      {children}
    </div>
  );
}

useRef(一貫性を保つべき情報)

useRefフックとそのcurrentプロパティは、DOM要素だけでなく、コンポーネントが存在する間、一貫性を保つ任意の値や状態を保持するために使用することができます。これにより、コンポーネントのライフサイクル全体で一貫性を保つべき情報を持つことが可能になります

まずは、useRefとcurrentプロパティを使用して、コンポーネントのライフサイクル全体で変数の値を保持し、それを更新する例
useRefを使用してカウンターの値を保持するReactコンポーネントです

import { useRef } from 'react';

function Counter() {
  // カウンタ値を保持するために ref を初期化します
  const counterRef = useRef(0);

  const incrementCounter = () => {
    counterRef.current += 1;
    console.log(`Counter is now: ${counterRef.current}`);
  };

  return (
    <div>
      <button onClick={incrementCounter}>Increment Counter</button>
    </div>
  );
}

export default Counter;

ポイント
useStateフックを使ってカウンターのような状態を管理すると、その状態が変更されるたびにコンポーネントが再レンダリングされます。
しかし、useRefを用いると、その値が変更されても再レンダリングは引き起こされません。これにより、アニメーションの状態や進行状況など、再レンダリングによって変更されるべきでない情報を安全に保持することができます。
言い換えると、useRefフックを使用することでアンマウントされたコンポーネントの状態も保持することが可能で、currentプロパティを通じて値を持ち続けることができます。

GSAPのコンテキストを保持するためにも使用されます
*GSAPのコンテキストとは、特定のアニメーションの実行環境を指します。これには、アニメーションが影響を及ぼすDOM要素、アニメーションのパラメータ(例えば、アニメーションの長さやイージング関数)、そしてアニメーションがどのように進行するかを制御するためのGSAPのメソッドやプロパティが含まれます

このコードの中で、useRefは2つの目的のために使われています。
1つ目はboxRefを通してDOM要素を参照すること、
2つ目はctxを通してGSAPのアニメーションコンテキストを保持することです。

const { useEffect, useRef, useState } = React;

const randomX = gsap.utils.random(-200, 200, 1, true);

function Box({ children, endX }) {    
  const boxRef = useRef(); //DOM要素を参照用
  const ctx = useRef();  //GSAPのコンテキストを保持用

  useEffect(() => {
//GSAPコンテキストを作成します(アニメーションの全体的な設定や状況を管理するため)
//ここでは特に何も設定していないので、空の関数(() => {})を渡しています
//この新たに作られたコンテキストはctx.currentに格納され
//コンポーネントがアンマウントされるとき(return () => ctx.current.revert();)でクリーンアップ
    ctx.current = gsap.context(() => {}); 
    return () => ctx.current.revert();
  }, [ctx]);

  useEffect(() => {
//ctx.current.addを通じて先ほどのGSAPコンテキストに追加
//endXの値が変わるたびに新しいアニメーションが作成されて追加されます
    ctx.current.add(() => {
      gsap.to(boxRef.current, { //Boxコンポーネントのx座標をendXに移動させるアニメーション
          x: endX
        });
    });
  }, [endX]);
  
  return <div className="box" ref={boxRef}>{children}</div>;
}

function App() {  
  const [endX, setEndX] = useState(0);
    
  return (
    <div className="app">
      <button onClick={() => setEndX(randomX())}>Pass in a randomized value</button>
      <Box endX={endX}>{endX}</Box>
    </div>
  );
}

最初のuseEffectフック内で、ctx.currentに新しいGSAPコンテキストを割り当てます。
このコンテキストは最初は空です(gsap.context(() => {}))
その後、endXが変更されるたびに、新しいアニメーションがこのコンテキストに追加されます(ctx.current.add(…))

ScrollTrigger

特定のDOM要素に対するスクロールトリガーアニメーションの設定
useRefを使用して特定のDOM要素への参照を保持し、gsapとScrollTriggerを使用してその要素に対するスクロールトリガーアニメーションを設定します

import React, { useRef, useEffect } from 'react';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger);

function MyComponent() {
  const ref = useRef(null);

  useEffect(() => {
    gsap.to(ref.current, {
      scrollTrigger: {
        trigger: ref.current,
        start: 'top center',
        end: 'bottom center',
        scrub: true,
      },
      x: 100,
    });
  }, []);

  return <div ref={ref}>Hello, world!</div>;
}

export default MyComponent;

余談:スクロール位置の追跡
useRefを使用してスクロール位置を保持し、windowオブジェクトのscrollイベントをリスニングしてスクロール位置を更新します。

function ScrollComponent() {
  const scrollPosition = useRef(0);

  useEffect(() => {
    const updateScrollPosition = () => {
      scrollPosition.current = window.scrollY;
      console.log(scrollPosition.current); 
    };

    window.addEventListener('scroll', updateScrollPosition);

    return () => {
      window.removeEventListener('scroll', updateScrollPosition);
    };
  }, []); // スクロールイベントリスナーはユーザーがスクロールするたびに実行されます

  return (
    <div>
      {/* component content */}
    </div>
  );
}

export default ScrollComponent;

親子間でタイムラインを渡す

タイムラインも、他のデータと同様にpropsとして子コンポーネントに渡すことができます

  • 親コンポーネントから子
    一般的には親が子の動作を制御する場合に使用されます。これは親が全体の状態を把握し、子がそれに従う階層型の組織を作るのに役立ちます
  • 子から親へのデータの流れ
    子コンポーネントがより自立したものであるべき場合、またはそれらが他の親コンポーネントと独立して再利用されるべき場合に使用されます。子から親へのデータの流れを設計することで、子コンポーネントは特定のアクションが発生したときに親に通知し、親はそれに対応することができます

親コンポーネントから子コンポーネントへタイムラインを渡す方法
Appコンポーネント(親コンポーネント)がタイムラインを作成し、それをBoxとCircle(子コンポーネント)にpropsとして渡しています。

const { useLayoutEffect, useRef, useState } = React;

//3 Boxはpropsとしてタイムライン(tl)を受け取ります
function Box({ children, timeline, index }) {
  const el = useRef();
 //4 受け取ったタイムラインを用いてアニメーションを定義
  useLayoutEffect(() => {    
    timeline && timeline.to(el.current, { x: -100 }, index * 0.1);
  }, [timeline, index]);
  
  return <div className="box" ref={el}>{children}</div>;
}
//3 Circleはpropsとしてタイムライン(tl)を受け取ります
function Circle({ children, timeline, index, rotation }) {
  const el = useRef();
  //4 受け取ったタイムラインを用いてアニメーションを定義
  useLayoutEffect(() => {   
    timeline && timeline.to(el.current, {  rotate: rotation, x: 100 }, index * 0.1);
  }, [timeline, rotation, index]);
  
  return <div className="circle" ref={el}>{children}</div>;
}

function App() {    
  const [reversed, setReversed] = useState(false);
//1 AppコンポーネントはuseStateフックを使ってタイムライン (tl) を初期化
  const [tl, setTl] = useState(); 
//2 useLayoutEffect フックを使用してタイムラインを作成
//gsap.timeline()を使用して新しい GSAP タイムラインを作成し
//それを setTlを使用してステートに設定しています
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      const tl = gsap.timeline();
      setTl(tl);
    });
    return () => ctx.revert();
  }, []);
  
  useLayoutEffect(() => {
    tl && tl.reversed(reversed);
  }, [reversed, tl]);
     
  return (
    <div className="app">   
      <button onClick={() => setReversed(!reversed)}>Toggle</button>
      <Box timeline={tl} index={0}>Box</Box>
      <Circle timeline={tl} rotation={360} index={1}>Circle</Circle>
    </div>
  );
}

export default App;

子コンポーネントから親コンポーネントにアニメーションを通知する方法
親コンポーネントはコールバック関数をpropsとして子コンポーネントに渡し、子コンポーネントは必要に応じてその関数を実行

const { useLayoutEffect, useRef, useState, useCallback } = React;

//Boxコンポーネントを定義します
//このコンポーネントは親からaddAnimation関数とindexを受け取り
//それ自体のDOMノードを参照するためにuseRefを使用します
function Box({ children, addAnimation, index }) {
  const el = useRef(); 
  //Boxコンポーネントのレンダリング後に実行される
  //useLayoutEffectフックを設定します。
  //このフックの中で、GSAPコンテクストを作成し、
  //アニメーションを作成して親のタイムラインに追加します
  useLayoutEffect(() => {
    console.log("Box effect");
    const ctx = gsap.context(() => {
      const animation = gsap.to(el.current, { x: -100 });
      addAnimation(animation, index);
    });  
    return () => ctx.revert();
  }, [addAnimation, index]);
  
  return <div className="box" ref={el}>{children}</div>;
}

//Circleコンポーネントはrotationプロパティも受け取っています
function Circle({ children, addAnimation, index, rotation }) {
  const el = useRef(); 
  useLayoutEffect(() => {
    console.log("Circle effect");
    const ctx = gsap.context(() => {
      const animation = gsap.to(el.current, { rotate: rotation, x: 100 });
      addAnimation(animation, index);
    });  
    return () => ctx.revert();
  }, [addAnimation, index, rotation]);
  
  return <div className="circle" ref={el}>{children}</div>;
}

//親コンポーネントではGSAPのタイムライン(tl)とアニメーションの再生方向(reversed)をステートとして持っています
function App() {    
  const [reversed, setReversed] = useState(false);  
  const [tl, setTl] = useState();
//親コンポーネントの初回レンダリング後に
//useLayoutEffectフックを実行
//ここではGSAPコンテクストを作成し、新しいGSAPタイムラインを作成し
//ステートに設定します。
  useLayoutEffect(() => {
    console.log("App effect");
    const ctx = gsap.context(() => {
      const tl = gsap.timeline();
      setTl(tl);
    });
    return () => ctx.revert();
  }, [])
//addAnimationという関数を定義
//子コンポーネントから渡されたアニメーションをタイムラインに追加するために使用します
  const addAnimation = useCallback((animation, index) => {  
    tl && tl.add(animation, index * 0.1);
  }, [tl]);

//タイムラインの再生方向を切り替えるuseLayoutEffectフックも定義
  useLayoutEffect(() => {    
    console.log("Reverse effect")
    tl && tl.reversed(reversed);
  }, [reversed, tl]);
  
  return (
    <div className="app">   
      <button onClick={() => setReversed(!reversed)}>Toggle</button>
      <Box addAnimation={addAnimation} index={0}>Box</Box>
      <Circle addAnimation={addAnimation} index={1} rotation="360">Circle</Circle>
    </div>
  );
}

export default App;

備考:useCallbackについて
useCallbackは、その引数として与えられた関数をメモ化(保存)します。
これは、特にこの関数が子コンポーネントに渡されてレンダリングのトリガーとなる可能性がある場合に有用です。
依存性配列(この場合は[tl])が変更されない限り、useCallbackは同じ関数の参照を返し続けます。
addAnimation関数がBoxとCircleコンポーネントに渡され、これらのコンポーネントがアニメーションを作成し、親のタイムラインに追加するために使用されます。
useCallbackを使用することで、tlが変更されない限り、addAnimation関数の新しいインスタンスが作成されることはなく、これにより不必要なレンダリングを防ぐことができます
親から子へのデータフローにおいて、タイムラインのような情報を子から親へ渡すためのコールバック関数を定義する際、useCallbackを使用するのは一般的なパターンとなっています。

Context

深くネストされたコンポーネントや、ツリーの全く異なる部分に存在するコンポーネント間でデータを共有する必要がある場合に役立ちます

const { useEffect, useLayoutEffect, useRef, useState, useContext, createContext } = React;
//createContext()関数を使って新しいContextを作成
const SelectedContext = createContext();

//Boxコンポーネントは、idをpropsとして受け取り、選択されたものであるかどうかを判断します
//そのために、useContextフックを使って現在選択されているidを取得します。
//これが可能なのは、このコンポーネントがSelectedContextプロバイダ内にネストされているからです
function Box({ children, id }) {  
  const el = useRef();
  const { selected } = useContext(SelectedContext);
//GSAPのコンテキストを作成
//このコンテキストを使用してアニメーション効果を追加および元に戻すこと。
  const ctx = gsap.context(() => {});
//コンポーネントがアンマウントされる際を考慮
  useEffect(() => {
    return () => ctx.revert(); 
  }, []);
// GSAPのコンテキストにアニメーション効果を追加
// 選択されたボックス(selected === id)だけを200px動かし、それ以外のボックスは元の位置(0)に留まる
  useLayoutEffect(() => {
    ctx.add(() => {
      gsap.to(el.current, {
        x: selected === id ? 200 : 0
      });
    });
  }, [selected, id, ctx]);
  
  return <div className="box" ref={el}>{children}</div>;
}
//Boxesコンポーネントは複数のBoxコンポーネントを描画
//それぞれに異なるidを渡します
function Boxes() {
  return (
    <div className="boxes">
      <Box id="1">Box 1</Box>
      <Box id="2">Box 2</Box>
      <Box id="3">Box 3</Box>
    </div>  
  );  
}
//Menuコンポーネントはラジオボタンが選択されると
//そのidがSelectedContextのstateにセットされます
//onChangeハンドラとuseContextフックを使って現在選択されているidを取得し、
//それをラジオボタンの状態と同期させます
function Menu() { 
  const { selected, setSelected } = useContext(SelectedContext);
  const onChange = (e) => {
    setSelected(e.target.value);
  }; 
  return (
    <div className="menu">      
      <label>
        <input 
          onChange={onChange} 
          checked={selected === "1"}
          type="radio"             
          value="1" 
          name="selcted"/> Box 1
      </label>    
      <label>
        <input 
          onChange={onChange} 
          checked={selected === "2"}
          type="radio"             
          value="2" 
          name="selcted"/> Box 2
      </label>  
      <label>
        <input 
          onChange={onChange} 
          checked={selected === "3"}
          type="radio"             
          value="3" 
          name="selcted"/> Box 3
      </label>  
    </div>    
  );
}

//Appコンポーネントは、アプリケーションの全体をラップするContextプロバイダを持ちます
//ここでSelectedContext.Providerに値を渡すと、その値はツリー内のどの子コンポーネントからでもアクセスできます
//選択されたidのstateとその更新関数(setSelected)をvalueとして渡しています
function App() {    
  const [selected, setSelected] = useState("2");
  
  return (
    <div className="app">   
      <SelectedContext.Provider value={{ selected, setSelected }}>    
        <Menu />
        <Boxes />
      </SelectedContext.Provider>
    </div>
  );
}

export default App;

備考:一般的には依存配列が空([])の場合、副作用はuseEffect内で処理します
*例外もあります

forwardRefについて

ReactのforwardRefは、親コンポーネントから子コンポーネントへ「参照(ref)」を転送するための特殊な関数です。Reactのデータフローは基本的に親から子への一方通行ですが、forwardRefを使うことで、親コンポーネントが子コンポーネントの特定のDOM要素や、関数コンポーネントの特定のインスタンス値に直接アクセスできるようになります。

関数コンポーネントはインスタンスを持たないため、refを直接渡すことはできません。しかし、forwardRefを使うと、親コンポーネントからrefを受け取り、それを子コンポーネントの特定のDOM要素へ”転送”できます。これにより、親コンポーネントは子コンポーネントのDOM要素を直接操作することが可能となります
親コンポーネントが子コンポーネンのinput要素に直接アクセスしています

import React, { useRef, forwardRef, useEffect } from "react";

// 子コンポーネント
// refを直接受け取ることができます。
const ChildComponent = forwardRef((props, ref) => (
  <input ref={ref} type="text" />
));

// 親コンポーネント
function ParentComponent() {
  // refオブジェクトを作成
  const inputRef = useRef(null);

  // refオブジェクトを使ってDOM操作を行う
  useEffect(() => {
    if (inputRef.current) {
      inputRef.current.focus(); //ページが読み込まれたときにinput要素にフォーカスを当てる
    }
  }, []);

  return (
    <div>
      <h1>Focus on the Input Element</h1>
      {/* 子コンポーネントにrefを渡す */}
      <ChildComponent ref={inputRef} />
    </div>
  );
}

export default ParentComponent;

「useImperativeHandle」と組み合わせて使用することで、親コンポーネントが子コンポーネントの特定のメソッドを直接呼び出すことも可能になります。
useImperativeHandleはReactのフックで、親コンポーネントが子コンポーネントのref経由で提供するインスタンス値をカスタマイズするためのものです

AppコンポーネントがCircleコンポーネントのmoveToメソッド(GSAP)を直接呼び出しています

function App() { 
  // useRefを使って新しいrefを作成します。
  // このrefは後でCircleコンポーネントに割り当てられます。
  const circleRef = useRef();

  useLayoutEffect(() => {    
    // Appコンポーネントがマウントされた時、circleRefのmoveToメソッドを呼び出します。
    // このメソッドはCircleコンポーネント内部で定義されています(下参照)
    // moveToメソッドはCircle(div要素)を(300, 100)の位置に移動させます。
    circleRef.current.moveTo(300, 100);
  }, []);
    
  return (
    <div className="app">
      //作成したref(circleRef)をCircleコンポーネントに渡します。
      <Circle ref={circleRef} />
    </div>
  );
}

const Circle = forwardRef((props, ref) => {
  const el = useRef();
  useImperativeHandle(ref, () => {
    return {
      // useImperativeHandleを使って、親コンポーネントから呼び出せるメソッドを定義します
      // この例では、moveToというメソッドを定義しています。
      // moveToはxとyの2つの引数を取り、それらの位置にdiv要素(el.current)を移動させます
      moveTo(x, y) {
        gsap.to(el.current, { x, y });
      }
    };
  }, []);

  // div要素にrefを割り当てます。このdiv要素はmoveToメソッドを用いて移動します
  return <div className="circle" ref={el}></div>;
});

頻繁に更新が発生し、それが多くのコンポーネントの再レンダリングを引き起こす可能性がある場合(例えば、マウスイベントなど)などに利用できます
*forwardRefやuseImperativeHandleは比較的高度な特性であり、特別なケースでない限りは必要ないことが多いです

再利用可能なアニメーションの作成

同じアニメーションを何度も使いまわすために特定のアニメーションを一つの関数やコンポーネントにカプセル化するものです。このカプセル化されたアニメーションは、アプリケーションのどこでもインポートして使用することができます。

子コンポーネント(FadeIn)
アニメーションを処理する新しいコンポーネント(FadeIn)を作成しています
FadeInコンポーネントは、子要素(children)とアニメーションの設定(vars)をpropsとして受け取ります。
親コンポーネント
FadeInコンポーネントを使用して、子要素に対してfadeInアニメーションを適用します。
アニメーションの設定(この場合、x座標を100にする)はvars propとして渡されます

import { useLayoutEffect, useRef } from "react";
import gsap from "gsap";

// フェードインエフェクトを適用するコンポーネント。エフェクトを適用する要素と設定をpropsとして受け取ります。
function FadeIn({ children, vars }) {
  // レンダリングする要素とアニメーションを参照するためのrefを設定
  const el = useRef();
  const animation = useRef(); 
  
  // レイアウトエフェクトフックを使用し、フェードインエフェクトを適用する。
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
      // gsap.fromメソッドでアニメーションを設定。開始時のopacityを0に設定し、
      // propsで渡されたその他の設定(vars)を適用します。
      animation.current = gsap.from(el.current.children, { 
        opacity: 0,
        ...vars
      });
    });

    // コンポーネントがアンマウントされた時、gsap.context()が作成した変更を元に戻す。
    return () => ctx.revert();       
  }, []);
  
  // 子コンポーネントをレンダリング
  return <span ref={el}>{children}</span>;
}

// アプリケーションのメインコンポーネント
function App() {      
  // Boxコンポーネントに対してFadeInエフェクトを適用
  return (
    <FadeIn vars={{ x: 100 }}>
      <div className="box">Box</div>
    </FadeIn>
  );
}

// Appコンポーネントをエクスポート
export default App;

アニメーションインスタンス
アニメーションの制御(進行状況、再生/一時停止、逆再生、時間など)を可能にします。
propsを通じてこれらのインスタンスを渡すことはできませんが、React の forwardRef を使用すると、FadeInコンポーネントが内部で生成したアニメーションインスタンスを、親の Appコンポーネントに公開することができます
親コンポーネントは ref.current を通じてアニメーションインスタンスにアクセスできます
*親でアニメーションインスタンスの逆再生状態を切り替えるためにforwardRefを使用する例

const { useLayoutEffect, useEffect, useRef, forwardRef } = React;

// FadeInコンポーネントは、ref 引数を受け取り、それを用いてアニメーションインスタンスを公開します
const FadeIn = forwardRef(({ children, x = 0 }, ref) => {
  const el = useRef(); // アニメーションを適用する要素を参照するために使用
  const animation = useRef(); // アニメーションインスタンスを保持するために使用 
  // 初回レンダリング時にアニメーションインスタンスを作成します
  useLayoutEffect(() => {
    const ctx = gsap.context(() => {
   //GSAPのアニメーションインスタンスを作成し、それを `animation.current` に代入します
      animation.current = gsap.from(el.current.children, { 
        opacity: 0,
        x 
      });
    });
    return () => ctx.revert();
  }, []);

  // forwardRefから提供されたrefを用いてアニメーションインスタンスを公開します
  useEffect(() => {
    // refが関数なら、その関数をanimation.currentを引数にして実行します
    // これにより、親コンポーネントはその関数の引数としてアニメーションインスタンスを取得します
    if (typeof ref === "function") {
      ref(animation.current);
    } 
    //refがオブジェクトなら、アニメーションインスタンスをそのcurrentプロパティに割り当てます
    //これにより、親コンポーネントはref.currentを通じてアニメーションインスタンスにアクセスできます
    else if (ref) {
      ref.current = animation.current;
    }
  }, [ref]);

  return <span ref={el}>{children}</span>
});

function App() {  
  const animation = useRef(); //FadeIn コンポーネントから受け取ったアニメーションインスタンスを保持するために使用します
  // アニメーションインスタンスの逆再生状態を切り替えます
  const toggle = () => {
    animation.current.reversed(!animation.current.reversed());
  };
  
  return (
    <div className="app">
      <div>
        <button onClick={toggle}>Toggle</button>
      </div>
      <FadeIn  x={100} ref={animation}>
        <div className="box">Box </div>
      </FadeIn>
    </div>
  );
}

export default App;

GSAPのRegisterEffect()

GSAPの特定の機能で、あらかじめ定義したアニメーションエフェクトを名前付きで登録します。これにより、アニメーションエフェクトを簡単に再利用することができますが、これはGSAPライブラリ内での再利用です

// Reactの機能とGSAPライブラリをインポート
import { useLayoutEffect, useRef, forwardRef } from "react";
import gsap from "gsap";

// GSAPのエフェクトを登録。今回は名前を"spin"とし、360度回転させるエフェクトを設定しています。
gsap.registerEffect({
  name: "spin",
  effect: (targets, config) => {
    return gsap.to(targets, {
      rotation: 360,
      ...config
    });
  }
});

// エフェクトを適用するコンポーネント。エフェクトの名前と対象をpropsとして受け取ります。
const GsapEffect = forwardRef(({ children, effect, targetRef, vars }, ref) => {  
  const animation = useRef();
  const ctx = gsap.context(() => {});
  
  // レイアウトエフェクトフックを使用し、エフェクトを適用する。
  useLayoutEffect(() => {
    if (gsap.effects[effect]) {
      ctx.add(() => {
        animation.current = gsap.effects[effect](targetRef.current, vars);
      });
    }
  }, [ctx, effect, targetRef, vars]);
    
  // 子コンポーネントをレンダリング
  return <>{children}</>;
});

// 任意のコンテンツを持つBoxコンポーネント。エフェクトを適用する対象となります。
const Box = forwardRef(({ children }, ref) => {
  return <div ref={ref}>{children}</div>;
});

// アプリケーションのメインコンポーネント
function App() {      
  const box = useRef();
  
  // Boxコンポーネントに対して"spin"エフェクトを適用
  return (
    <GsapEffect targetRef={box} effect="spin">
      <Box ref={box}>Hello</Box>
    </GsapEffect>
  );
}

export default App;

DOMから要素を削除するアニメーション

Reactは通常、状態が変化したときにすぐにDOMを更新します。
そのため、要素がDOMから削除されるとすぐに消えてしまい、アニメーションが完全に実行される間がありません。
そこでアニメーションが完了した後にのみ要素を削除することで、要素がDOMから消える前にアニメーションを完全に表示することができます。

アニメーションが完了すると、実際に項目が状態から削除されます
*Reactのコンポーネントライフサイクルと同期させるために、useStateを使ってctx(gsap.context)をステートとして管理します

const { useState, useLayoutEffect } = React; 

function App() {
  // itemsという状態を作成、初期値は青、赤、紫
  const [items, setItems] = useState(() => [
    { id: 0, color: "blue" },
    { id: 1, color: "red" },
    { id: 2, color: "purple" }
  ]);

  // gsapのコンテクストを作成、このコンテクストはアニメーションを管理するためのもの
  const [ctx] = useState(() => gsap.context(() => {}));

  // 項目を削除する関数、引数には削除したい項目の情報が入ります
  const removeItem = (value) => {
    // 項目の状態を更新します。更新後の状態は、引数の項目を除いたものになります。
    setItems((prev) => prev.filter((item) => item !== value));
  };

// レンダリング後のレイアウト調整のための副作用関数です。
  useLayoutEffect(() => {
// gsapのコンテクストに"remove"という名前のアニメーションを追加します
//このアニメーションは透明度を0にして項目を消す動作を行います。
    ctx.add("remove", (item, target) => {
      gsap.to(target, {
        opacity: 0, 
        onComplete: () => removeItem(item) // アニメーションが終わったら項目を削除
      });
    });
    // アンマウントするときに実行されるクリーンアップ関数
    return () => ctx.revert();
  }, []); // 依存配列が空なので、この副作用は一度だけ実行されます。

// 項目一つ一つに対して、色とクリックイベントを付けたdiv要素を生成
// クリックイベントです。クリックすると"remove"アニメーションを実行します。
  return (
    <div className="app boxes">
      {items.map((item) => (
        <div
          className={`box ${item.color}`} 
          key={item.id}   
          onClick={(e) => ctx.remove(item, e.currentTarget)}
        >
          Click Me
        </div>
      ))}
    </div>
  );
}
export default App;