← All articles

[ Blog ]

September 1, 2026

7 min read

Category: Insights

useGSAP Not Cleaning Up on Deps Change? Here's the Fix

useGSAP dependencies cleanup, explained: why tweens stack and ScrollTriggers survive unmount, and the revertOnUpdate / ref-kill fix for @gsap/react.

GSAPReactuseGSAPScrollTriggerNext.jsAnimation
useGSAP Not Cleaning Up on Deps Change? Here's the Fix

Two bug reports that look unrelated, same root cause: a useGSAP hook with a dependencies array that keeps leaving old tweens or triggers alive. The first time I hit this it was a view-switcher cross-fade where rapid clicks landed on whichever tween happened to finish last, not the current view. The second time it was a ScrollTrigger still firing onToggle on a DOM node that had already unmounted from the tree. Both come from the same misread of how useGSAP cleans up — and the fix is one option most people never pass.

The symptom checklist

You're hitting this if any of these sound familiar:

  • A useGSAP(..., { dependencies: [x] }) tween that fires on every change of x — and rapid changes end on a random previous value instead of the latest one. It looks fine with two states because the tween on the way back re-sets the target after the stragglers catch up; add a third state and it becomes obvious.
  • A ScrollTrigger built inside a component that conditionally renders null on some route — the element is gone from the DOM, but the trigger keeps evaluating scroll position against a detached node, and whatever it toggled (a theme class, a Zustand flag, a background color) stays stuck at its last value.
  • Nothing throws. No console error, no warning. The animation state is just wrong, and it only shows up under rapid interaction or on a route you don't test by hand every time.

Root cause: useGSAP reverts on unmount, not on re-run

useGSAP from @gsap/react wraps your animation code in a gsap.context() and — critically — only calls .revert() when the component unmounts. A dependencies array gives you a re-run of the callback on change, exactly like useEffect. What it does not give you, unlike useEffect, is a call to your cleanup function before that re-run. The component is still mounted, so React never unmounts it, so useGSAP never reverts. Every dependency change just piles a new context's tweens/triggers on top of the last one's.

This is documented GSAP behavior, not a bug — it's just the opposite of what years of useEffect habits train you to expect. In useEffect, the cleanup runs before every re-run and on unmount. In useGSAP, the cleanup you return only runs on unmount unless you opt in.

'use client';
import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import { gsap } from 'gsap';

// Broken: fires on every change of `activeIndex`, never cleans up the previous run
function ViewSwitcher({ activeIndex }: { activeIndex: number }) {
  const scope = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      gsap.to(`.view-${activeIndex}`, { opacity: 1, duration: 0.4 });
    },
    { dependencies: [activeIndex], scope }
  );

  return <div ref={scope}>{/* views */}</div>;
}

Click through three views fast and you'll see it: three tweens targeting three different elements, all running concurrently, and whichever one has the shortest remaining time wins — not necessarily the one that matches activeIndex right now.

Fix 1: revertOnUpdate: true

The direct fix is a second config option most people never reach for. Pass revertOnUpdate: true alongside dependencies, and useGSAP reverts the previous context before running the new one — on every dependency change, not just on unmount:

useGSAP(
  () => {
    const trigger = ScrollTrigger.create({
      trigger: node.current,
      start: 'top center',
      onToggle: (self) => setTheme(self.isActive ? 'dark' : 'light'),
    });

    return () => {
      trigger.kill();
      resetTheme();
    };
  },
  { dependencies: [pathname], revertOnUpdate: true }
);

This is exactly what fixed the stranded-trigger case: a persistent layout component that conditionally returns null on one route. The element unmounts from the DOM but the parent component doesn't — so without revertOnUpdate, the ScrollTrigger built for it just keeps existing, evaluating against a node that's no longer in the tree, and the theme flag it last set never gets reset. revertOnUpdate: true makes the returned cleanup function run on every pathname change, which kills the trigger and resets the flag before the next route's context is created.

Fix 2: the ref-kill, for when a stale onComplete or stagger has to die

revertOnUpdate covers most cases, but there's a gap: gsap.context().revert() (and gsap.killTweensOf(), which people reach for instead) does not reliably cancel a timeline's pending callbacks, and it silently fails to kill a tween created with a nonzero stagger. If your effect creates a tween with an onComplete that must not fire after the deps change — or a staggered tween — keep the tween/timeline on a ref and kill it explicitly at the top of the effect, before creating the next one:

function StaggerList({ items }: { items: string[] }) {
  const tweenRef = useRef<gsap.core.Timeline | null>(null);

  useGSAP(() => {
    tweenRef.current?.kill();

    tweenRef.current = gsap.timeline().to('.item', {
      opacity: 1,
      stagger: 0.05,
      onComplete: () => markDone(items),
    });
  }, [items]);
}

Killing the ref at the top guarantees the previous timeline — and its onComplete — is dead before the new one exists, regardless of whether useGSAP's own revert runs. This is the pattern to reach for whenever a tween or timeline owns a callback or a stagger, even alongside revertOnUpdate.

revertOnUpdate vs the ref-kill

revertOnUpdate: trueRef-kill (kill at top of effect)
Fixes stacking tweensYesYes
Fixes a stranded ScrollTrigger on unmount-adjacent nodesYesOnly if you kill it explicitly
Kills a timeline's pending onComplete reliablyNot guaranteedYes
Kills a tween created with staggerNot guaranteedYes
Code changeOne config flagA ref + explicit .kill()
When to useDefault choice for any deps-driven useGSAP that builds a trigger/subscriptionWhenever a tween/timeline has a callback or stagger that must not survive

In practice: reach for revertOnUpdate: true by default on any useGSAP call that has a dependencies array and creates something with teardown (a ScrollTrigger, a listener, a subscription). Add the ref-kill on top when that something is a tween or timeline carrying an onComplete or a stagger.

The lesson

In useGSAP, a dependency array alone buys you a re-run — never a cleanup. Anything your effect creates that needs teardown (a ScrollTrigger, a subscription, a tween with an onComplete) needs either revertOnUpdate: true or an explicit ref-kill. The failure mode is always silent: nothing errors, the animation or the state it toggled is just stuck one step behind reality. If you're debugging a GSAP animation that "sometimes" ends on the wrong value, or a flag that stays stuck after navigating away from a page, check your useGSAP deps array first.

If you're still getting familiar with ScrollTrigger itself, start with the GSAP ScrollTrigger tutorial on pin, scrub, and parallax — this cleanup rule applies to every trigger you build with it. If you're choosing an animation library in the first place, see the honest breakdown in GSAP vs Framer Motion. And if your useGSAP triggers are meant to sync with scroll position, that only works reliably once the scroll itself is smooth and single-clock — see smooth scroll in Next.js with GSAP & Lenis.

FAQ

Why does my useGSAP tween run twice in development?

That's React Strict Mode double-invoking effects, a separate issue from this one — useGSAP handles Strict Mode correctly on its own. If you're seeing tweens stack rather than just double-fire once in dev, you're hitting the deps-cleanup issue above, not Strict Mode.

Does gsap.context().revert() kill ScrollTrigger instances too?

Yes — revert() cleans up ScrollTriggers, tweens, and timelines created inside that context. The problem in this post isn't that revert doesn't work; it's that useGSAP only calls it on unmount unless you pass revertOnUpdate: true, so a component that stays mounted across a dependency change never gets that revert call for free.

Is killTweensOf enough instead of a ref-kill?

Not always. gsap.killTweensOf() targets tweens by their target element, but it does not reliably cancel a timeline's pending onComplete callback, and it can silently miss a tween created with a nonzero stagger. Keep the tween/timeline reference on a ref and call .kill() on it explicitly when either of those is in play.

Debugging a stuck animation state like this on your own project, or want a useGSAP audit before it ships? Get in touch — GSAP lifecycle bugs like this are exactly the kind of thing that only show up under real user interaction, and they're cheap to fix once you know where to look.

Related posts