
Всем привет!
Давайте представим, что от бизнеса поступил запрос: "Нам надо, чтобы при входе на сайт сразу же открывалось модальное окно авторизации для сканирования клиентского QR-кода."
Вы запускаете стабильно работающий проект, применяете useEffect с необходимой фичей и пустой зависимостью, а затем - начинаете тестировать.
И вот незадача: модальное окно открывается на миллисекунду и моментально закрывается.
При этом: логи в порядке, стейты меняются корректно, но модальное окно живет своей жизнью и наотрез отказывается работать, как ей предписано.
Я потратил довольно длительное время на поиски этой ошибки. Но затем, удалив setTimeout, который мы использовали для анимирования модального окна, заметил, что все стало работать корректно.
Длительный поиск вариантов анимирования открытия/закрытия модального окна не помог.
Но стоит отметить, что я узнал множество способов и комбинаций для создания красивых визуальных эффектов: как при помощи сторонних зависимостей, так и нативных.
Использование каких либо библиотек я отбросил сразу, но и смириться с тем, что все модальные окна на проекте отныне будут работать без красивых анимаций я не мог.
Поэтому сразу же приступил к поискам решений данной проблемы.
В процессе я совершенно случайно наткнулся на статью @GragertVD, которая, словом, не подходила под мои критерии поиска.
В ходе чтения - я открыл совершенно новый для себя обработчик события onAnimationEnd и наконец решил указанную выше проблему.
А вот каким образом, сейчас расскажу.
Данную статью, условно, можно разбить на три пункта:
Почему
setTimeoutдля анимации контента следует применять с осторожностью;Как я переписал логику с CSS-анимациями и что делает браузерное событие
onAnimationEnd;Мое универсальное решение для любых компонентов с анимацией на текущем проекте.
I. Анализ проблемы: конфликт состояний view и animation
Мой первоначальный подход:
import ... const UIModal: FC<IProps> = ({ open, onClose, ... }) => { const [animation, setAnimation] = useState(true); // true = появление const [view, setView] = useState(false); // контролирует рендеринг в DOM useEffect(() => { setAnimation(open); // Устанавливаем направление анимации if (open) { setView(true); // Показываем элемент } else { // ❌ ПРОБЛЕМНОЕ МЕСТО setTimeout(() => setView(false), 300); } }, [open]); if (!view) return <></>; return createPortal( <div className={`... ${animation ? 'animate-opacity-expand' : 'animate-opacity-collapse'}`}> <UIBlock className={`... ${animation ? 'animate-slide-up' : 'animate-slide-down'}`}> {/* содержимое модалки */} </UIBlock> </div>, document.body ); };
Стили в Tailwind config
// tailwind.config.ts { keyframes: { 'opacity-expand': { '0%': { opacity: '0' }, '100%': { opacity: '1' }, }, 'opacity-collapse': { '0%': { opacity: '1' }, '100%': { opacity: '0' }, }, 'slide-up': { '0%': { transform: 'translateY(100%)' }, '100%': { transform: 'translateY(0)' }, }, 'slide-down': { '0%': { transform: 'translateY(0)' }, '100%': { transform: 'translateY(100%)' }, }, }, animation: { 'slide-up': 'slide-up 0.2s ease forwards', 'slide-down': 'slide-down 0.2s ease forwards', 'opacity-expand': 'opacity-expand 150ms ease-in-out forwards', 'opacity-collapse': 'opacity-collapse 150ms ease-in-out forwards', } }
Причина конфликта, или рассогласование между двумя состояниями:
animation- управляет направлением анимации (true= появление,false= исчезновение)view- управляет фактическим присутствием элемента в DOM
Сценарий конфликта при инициализации:
// Компонент монтируется с open = true useEffect(() => { setAnimation(true); // ← "Анимация" setView(true); // ← "Появись в DOM" }, []); // Но почти мгновенно (из-за логики родителя) open становится false useEffect(() => { setAnimation(false); // ← Говорим: "анимируй исчезновение" setTimeout(() => setView(false), 300); // ← Говорим: "через 300ms убери из DOM" }, [open]); // 3. React пытается одновременно: // - Запустить анимацию появления (т.к. view = true и animation = true) // - И сразу же анимацию исчезновения (т.к. animation стало false) // - И запланировать удаление из DOM через 300ms
II. Почему удаление setTimeout временно помогло?
Думается, уже понятно :D
// БЫЛО (проблемный код): } else { setTimeout(() => setView(false), 300); // ← УБИРАЕМ ЭТУ СТРОКУ } // СТАЛО: } else { // setView(false); // ← сразу удаляем из DOM без анимации }
Что происходило:
Убирая
setTimeout, мы немедленно удаляли элемент из DOM приopen = falseНе было конфликта между анимацией появления и исчезновения
Но и не было красивых анимаций
II. Как работает onAnimationEnd
onAnimationEnd - это нативное браузерное событие, которое срабатывает точно в момент завершения CSS-анимации. Именно оно стало для меня идеальным решением синхронизации React-состояния с фактическим завершением анимаций:
const UIModal: FC<IProps> = ({ open, onClose, closeButton = true, title, footer, children, className = '', ...props }) => { const [animation, setAnimation] = useState(true); const [view, setView] = useState(false); useEffect(() => { setAnimation(open); if (open) setView(true); }, [open]); if (!view) return null; return createPortal( <div onClick={onClose} className={`... ${animation ? 'animate-opacity-expand' : 'animate-opacity-collapse'}`} onAnimationEnd={() => { if (!animation) setView(false); }} > <UIBlock onClick={(e) => e.stopPropagation()} className={`... ${animation ? 'animate-slide-up' : 'animate-slide-down'} ${className}`} {...props} > <div className="flex-end"> {!!title && <div className="flex-1">{title}</div>} {closeButton && ( <div className={`... ${!title ? 'absolute top-0 right-0' : ''}`}> <CrossCloseCircleIcon onClick={onClose} /> </div> )} </div> {children} {!!footer && footer} </UIBlock> </div>, document.body ); };
Как это решает проблему конфликта состояний
Сценарий открытия модалки:
// 1. open становится true useEffect → setAnimation(true) + setView(true) // 2. Рендерится с animation=true → запускается анимация появления // 3. onAnimationEnd НЕ срабатывает для анимации появления (т.к. условие: !animation = false) // 4. Модалка остается видимой
Сценарий закрытия модалки:
// 1. open становится false useEffect → setAnimation(false) // но setView(true) остается! // 2. Рендерится с animation=false → запускается анимация исчезновения // 3. Когда анимация завершается → срабатывает onAnimationEnd // 4. Проверка: !animation = true → setView(false) // 5. Элемент удаляется из DOM
Ключевые механизмы решения:
Разделение ответственности:
animation → управляет направлением анимации view → управляет присутствием в DOM onAnimationEnd → синхронизирует ихУсловие в onAnimationEnd:
onAnimationEnd={() => { if (!animation) { // ← Срабатывает ТОЛЬКО для анимации исчезновения setView(false); // ← Убираем из DOM после завершения анимации } }}Никаких магических чисел задержки в setTimeout()!. Единый источник истины для времени:
// Время анимации определяется ТОЛЬКО в tailwind конфиге: animate-opacity-expand: 150ms ease-in-out forwards; animate-opacity-collapse: 150ms ease-in-out forwards;
Что нам это даст?
1. Решение конфликта состояний
Больше нет гонки между:
Анимацией появления (
animation = true)Анимацией исчезновения (
animation = false)Удалением из DOM (
view = false)
2. Поддержка прерывания анимаций
// Пользователь быстро открыл-закрыл-открыл модалку: open → close → open // Старый подход: таймеры накладывались друг на друга // Новый подход: каждая анимация управляется независимо
III. Универсальный компонент:
На основе мини-исследования стало целесообразным вынос данной логики в отдельный хук:
// hooks/useAnimation.ts const useAnimation = (visible: boolean) => { const [animation, setAnimation] = useState(true); const [render, setRender] = useState(false); useEffect(() => { setAnimation(isVisible); if (visible) setRender(true); }, [visible]); const handleAnimationEnd = () => { if (!animation) setRender(false); }; return { render, animation, handleAnimationEnd }; };
Применяем хук к UIModal
const UIModal: FC<IProps> = ({ open, onClose, closeButton = true, title, footer, children, className = '', ...props }) => { const { shouldRender, animation, handleAnimationEnd } = useAnimation(open); if (!shouldRender) return null; return createPortal( <div onClick={onClose} className={`absolute inset-0 z-50 flex-center bg-ui-gray-bg-overlay backdrop-blur-sm ${ animation ? 'animate-opacity-expand' : 'animate-opacity-collapse' }`} onAnimationEnd={handleAnimationEnd} > <UIBlock onClick={(e) => e.stopPropagation()} className={`relative shadow-xl flex flex-col ${ animation ? 'animate-slide-up' : 'animate-slide-down' } ${className}`} {...props} > <div className="flex-end"> {!!title && <div className="flex-1">{title}</div>} {closeButton && ( <div className={`flex-none w-12 h-12 flex-center ${!title ? 'absolute top-0 right-0' : ''}`}> <CrossCloseCircleIcon onClick={onClose} /> </div> )} </div> {children} {!!footer && footer} </UIBlock> </div>, document.body ); };
Что изменилось:
Убрали управление состояниями
animationиviewЗаменили на деструктуризацию хука:
const { shouldRender, animation, handleAnimationEnd } = useAnimation(open);Упростили логику - хук берет на себя всю работу с анимациями
ИТОГО: хватит гадать, когда закончится анимация.
История с setTimeout научила меня простой истине: не нужно пытаться угадать длительность анимации. Браузер уже знает, когда она завершится — благодаря onAnimationEnd.
Мой универсальный хук useAnimation решает главные проблемы:
Убирает конфликт состояний
Избавляет от магических чисел в коде
Работает с любыми CSS-анимациями
Выдерживает быстрое открытие/закрытие
Теперь все модалки проекта работают плавно и предсказуемо. А главное — этот подход масштабируется на любые анимированные компоненты.
P.S. (постскриптум)
Это моя первая публикация, буду рад любой критике. Спасибо, если дочитали до конца :-)
