setTimeout убил наши анимации: история спасения модальных окон
- четверг, 9 октября 2025 г. в 00:00:04
Всем привет!
Давайте представим, что от бизнеса поступил запрос: "Нам надо, чтобы при входе на сайт сразу же открывалось модальное окно авторизации для сканирования клиентского QR-кода."
Вы запускаете стабильно работающий проект, применяете useEffect
с необходимой фичей и пустой зависимостью, а затем - начинаете тестировать.
И вот незадача: модальное окно открывается на миллисекунду и моментально закрывается.
При этом: логи в порядке, стейты меняются корректно, но модальное окно живет своей жизнью и наотрез отказывается работать, как ей предписано.
Я потратил довольно длительное время на поиски этой ошибки. Но затем, удалив setTimeout
, который мы использовали для анимирования модального окна, заметил, что все стало работать корректно.
Длительный поиск вариантов анимирования открытия/закрытия модального окна не помог.
Но стоит отметить, что я узнал множество способов и комбинаций для создания красивых визуальных эффектов: как при помощи сторонних зависимостей, так и нативных.
Использование каких либо библиотек я отбросил сразу, но и смириться с тем, что все модальные окна на проекте отныне будут работать без красивых анимаций я не мог.
Поэтому сразу же приступил к поискам решений данной проблемы.
В процессе я совершенно случайно наткнулся на статью @GragertVD, которая, словом, не подходила под мои критерии поиска.
В ходе чтения - я открыл совершенно новый для себя обработчик события onAnimationEnd
и наконец решил указанную выше проблему.
А вот каким образом, сейчас расскажу.
Данную статью, условно, можно разбить на три пункта:
Почему setTimeout
для анимации контента следует применять с осторожностью;
Как я переписал логику с CSS-анимациями и что делает браузерное событиеonAnimationEnd;
Мое универсальное решение для любых компонентов с анимацией на текущем проекте.
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.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
Думается, уже понятно :D
// БЫЛО (проблемный код):
} else {
setTimeout(() => setView(false), 300); // ← УБИРАЕМ ЭТУ СТРОКУ
}
// СТАЛО:
} else {
// setView(false); // ← сразу удаляем из DOM без анимации
}
Что происходило:
Убирая setTimeout
, мы немедленно удаляли элемент из DOM при open = false
Не было конфликта между анимацией появления и исчезновения
Но и не было красивых анимаций
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;
Что нам это даст?
Больше нет гонки между:
Анимацией появления (animation = true
)
Анимацией исчезновения (animation = false
)
Удалением из DOM (view = false
)
// Пользователь быстро открыл-закрыл-открыл модалку:
open → close → open
// Старый подход: таймеры накладывались друг на друга
// Новый подход: каждая анимация управляется независимо
На основе мини-исследования стало целесообразным вынос данной логики в отдельный хук:
// 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 };
};
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-анимациями
Выдерживает быстрое открытие/закрытие
Теперь все модалки проекта работают плавно и предсказуемо. А главное — этот подход масштабируется на любые анимированные компоненты.
Это моя первая публикация, буду рад любой критике. Спасибо, если дочитали до конца :-)