useGSAP en React: el hook que cierra el ciclo
Qué hace el hook por encima de useLayoutEffect, sus tres opciones de configuración, contextSafe para lo que se crea después, y el modo estricto de React.
Animar en React tiene un problema que no existe en una página sin framework: los efectos se ejecutan dos veces en desarrollo, los componentes se desmontan y se vuelven a montar sin avisar, y los nodos que animas pueden ser reemplazados por otros idénticos en cualquier rerenderizado. useGSAP() es un hook oficial que envuelve gsap.context() y resuelve las tres cosas, y su valor no está en ahorrar líneas sino en que la limpieza deja de ser algo que hay que recordar.
- Instalar y registrar el hook correctamente.
- Usar
scopepara que los selectores no se salgan del componente. - Elegir entre
dependenciescon y sinrevertOnUpdate. - Aplicar
contextSafea todo lo que se cree después de la ejecución del hook.
Instalación y forma básica
El hook vive en un paquete aparte y la documentación recomienda registrarlo como un plugin más, para evitar discrepancias entre versiones de React.
npm install gsap @gsap/react
import { useRef } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export function Tarjeta() {
const contenedor = useRef(null);
useGSAP(
() => {
gsap.from(".titulo", { autoAlpha: 0, y: 24, duration: 0.6 });
gsap.from(".linea", { scaleX: 0, transformOrigin: "left", duration: 0.5, delay: 0.2 });
},
{ scope: contenedor }
);
return (
<article ref={contenedor}>
<h3 className="titulo">Nivel dios</h3>
<span className="linea" />
</article>
);
}
Ese componente funciona pegado tal cual. La documentación describe el hook como un sustituto directo de useEffect o useLayoutEffect que gestiona la limpieza usando gsap.context() por debajo: todas las animaciones, ScrollTriggers, Draggables e instancias de SplitText creadas durante su ejecución se revierten cuando el componente se desmonta.
Internamente aplica la técnica del efecto isomorfo: prefiere useLayoutEffect y cae a useEffect cuando no hay window, así que es seguro en entornos con renderizado en servidor. En el enrutador de aplicación de Next hay que poner la directiva de cliente al principio del archivo.
scope: el problema de los selectores
scope recibe una referencia y hace que todo el texto de selector de dentro del hook busque solo entre los descendientes de ese elemento.
Sin él, gsap.from(".titulo", ...) afecta a todos los elementos con esa clase de la página. Con veinte tarjetas en pantalla, el componente número tres anima los títulos de las veinte. El fallo es tan visual que se detecta enseguida, pero la solución habitual sin el hook —generar identificadores únicos o una referencia por elemento— ensucia mucho el componente.
Con scope, el mismo código funciona para las veinte instancias y cada una toca lo suyo. Es probablemente la opción que más justifica usar el hook.
dependencies y revertOnUpdate
El segundo parámetro puede ser el array de dependencias o el objeto de configuración. Las tres claves válidas son scope, dependencies y revertOnUpdate, y no hay más.
// Sin dependencias: se ejecuta una vez al montar
useGSAP(() => { /* ... */ }, { scope: contenedor });
// Con dependencias: se vuelve a ejecutar cuando cambian
useGSAP(() => { /* ... */ }, { scope: contenedor, dependencies: [abierto] });
// Forma corta cuando no necesitas mas configuracion
useGSAP(() => { /* ... */ }, [abierto]);
Aquí está el detalle que hay que entender bien. Por defecto, cuando una dependencia cambia, la función se vuelve a ejecutar pero lo anterior no se revierte: la reversión ocurre solo al desmontar, y la función de limpieza que devuelvas tampoco se ejecuta hasta entonces. Es deliberado, porque en muchos casos quieres que lo anterior siga vivo y solo añadir algo nuevo.
Con revertOnUpdate: true, cada cambio de dependencia revierte el contexto anterior y ejecuta tu función de limpieza antes de volver a ejecutar la función principal.
useGSAP(
() => {
ScrollTrigger.create({
trigger: contenedor.current,
start: "top 70%",
onEnter: () => marcarVisto(id),
});
},
{ scope: contenedor, dependencies: [id], revertOnUpdate: true }
);
La regla práctica: si la función crea instancias que deberían existir en lugar de las anteriores —ScrollTriggers, Draggables, SplitText, Observers—, quieres revertOnUpdate: true. Si solo lanza un tween que sustituye al anterior por sobrescritura, no hace falta.
React en modo estricto, que es el modo por defecto en desarrollo, monta el componente, lo desmonta y lo vuelve a montar. Sin limpieza, eso crea dos veces cada animación. Con gsap.from() el efecto es especialmente visible: la primera pasada mueve el elemento a su estado de partida, la segunda lee ese estado alterado como si fuera el natural, y el elemento acaba en un sitio incorrecto. Con el hook, la reversión del primer montaje deja los elementos limpios y la segunda pasada parte de cero. Es el motivo por el que la documentación oficial insiste tanto en la limpieza.
contextSafe: lo que se crea después
El contexto recoge lo que se cree durante la ejecución de la función. Todo lo que ocurra después —un manejador de clic, un temporizador, una promesa que se resuelve, una respuesta de red— queda fuera: no se recoge, no se limpia, y su texto de selector no usa el ámbito.
contextSafe() convierte una función cualquiera en una versión que sí queda dentro. Hay dos formas de acceder a ella.
Dentro del hook, llega como segundo argumento:
useGSAP(
(contexto, contextSafe) => {
const alPulsar = contextSafe(() => {
gsap.to(".panel", { x: 200, duration: 0.4 });
});
const boton = contenedor.current.querySelector(".abrir");
boton.addEventListener("click", alPulsar);
// La escucha SI hay que retirarla a mano
return () => boton.removeEventListener("click", alPulsar);
},
{ scope: contenedor }
);
Fuera del hook, viene en el objeto que devuelve, junto con context:
export function Panel() {
const contenedor = useRef(null);
const { contextSafe } = useGSAP({ scope: contenedor });
const abrir = contextSafe(() => {
gsap.to(".contenido", { height: "auto", duration: 0.4 });
});
return (
<div ref={contenedor}>
<button onClick={abrir}>Abrir</button>
<div className="contenido" />
</div>
);
}
Fíjate en que la segunda forma pasa el objeto de configuración como primer argumento, sin función. Es la forma documentada para cuando solo quieres contextSafe.
Y un aviso que la propia documentación subraya: envolver la función en contextSafe recoge las animaciones, pero no retira la escucha de eventos. Si la añadiste con addEventListener, sigue siendo tuya y va en la función de limpieza. Con un onClick de JSX no hay problema, porque React la retira sola.
Errores frecuentes
Los selectores tocan otros componentes. Falta scope.
La animación se duplica en desarrollo y no en producción. Es el modo estricto. Si ocurre con el hook puesto, revisa si estás creando algo fuera de su función.
Una animación creada en un clic no se limpia. Falta contextSafe.
Un gsap.from() deja el elemento en un sitio raro. Doble ejecución sin reversión entre medias. El hook lo arregla; si persiste, comprueba que no estás creando la animación fuera de él.
Flip no anima nada tras un rerenderizado. El estado apunta a nodos que React ha sustituido. Hay que pasar targets y poner data-flip-id.
Hay una lectura de este hook que ayuda a entender por qué existe y qué no va a resolver nunca. GSAP es imperativo: le das órdenes a elementos concretos y guardas referencias a los objetos que crea. React es declarativo: describes el resultado y él decide cuándo y cuántas veces reconstruir el DOM, y no promete que los nodos sobrevivan a nada. Esos dos modelos no se pueden reconciliar del todo, y cualquier intento de hacer GSAP “declarativo” acabaría en una capa que reimplementaría mal la mitad del motor. Lo que hace useGSAP() es más humilde y más eficaz: renuncia a reconciliar los modelos y se agarra a lo único que React sí garantiza con firmeza, que es el ciclo de vida del componente. Sabe cuándo un componente empieza a existir y cuándo deja de existir, y ata a esos dos instantes la creación y la reversión de todo lo imperativo. Nada más. Todo lo que quede fuera de esa ventana —una animación creada en un clic, un estado de Flip capturado antes de un rerenderizado, una referencia a un nodo que React ha sustituido— sigue siendo problema tuyo, y por eso contextSafe, targets y data-flip-id existen: son los puntos de sutura donde el modelo imperativo tiene que volver a engancharse al declarativo a mano. Reconocer esa frontera es lo que separa a quien usa el hook con confianza de quien lo pone y sigue teniendo bugs raros. La pregunta que resuelve casi cualquier duda de integración es siempre la misma: ¿esto se está creando durante la ejecución del hook, o después? Si es durante, está cubierto. Si es después, necesita que lo reincorpores explícitamente. No hay un tercer caso.
- Monta veinte instancias del mismo componente sin
scopey observa cómo cada una anima los elementos de todas. Añádelo y comprueba la diferencia. - Anima con
gsap.from()sin el hook, en modo estricto, y localiza el elemento que acaba en el sitio equivocado. - Crea una animación dentro de un
onClicksincontextSafe, desmonta el componente con la animación en marcha y comprueba el contador de tweens. - Compara el comportamiento con y sin
revertOnUpdate: trueen un hook que cree un ScrollTrigger y dependa de un estado. - Añade una escucha con
addEventListenerdentro del hook y verifica que hay que retirarla a mano aunque usescontextSafe.