← All articles
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.

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 ofx— 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
ScrollTriggerbuilt inside a component that conditionally rendersnullon 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: true | Ref-kill (kill at top of effect) | |
|---|---|---|
| Fixes stacking tweens | Yes | Yes |
Fixes a stranded ScrollTrigger on unmount-adjacent nodes | Yes | Only if you kill it explicitly |
Kills a timeline's pending onComplete reliably | Not guaranteed | Yes |
Kills a tween created with stagger | Not guaranteed | Yes |
| Code change | One config flag | A ref + explicit .kill() |
| When to use | Default choice for any deps-driven useGSAP that builds a trigger/subscription | Whenever 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.


