модалка открывается, и сразу закрывается
модалка открывается, и сразу закрывается

Всем привет!

Давайте представим, что от бизнеса поступил запрос: "Нам надо, чтобы при входе на сайт сразу же открывалось модальное окно авторизации для сканирования клиентского 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',
  }
}

Причина конфликта, или рассогласование между двумя состояниями:

  1. animation - управляет направлением анимации (true = появление, false = исчезновение)

  2. 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

Ключевые механизмы решения:

  1. Разделение ответственности:

    animation → управляет направлением анимации
    
    view → управляет присутствием в DOM
    
    onAnimationEnd → синхронизирует их
  2. Условие в onAnimationEnd:

    onAnimationEnd={() => {
     if (!animation) {  // ← Срабатывает ТОЛЬКО для анимации исчезновения
         setView(false);  // ← Убираем из DOM после завершения анимации
       }
     }}
  3. Никаких магических чисел задержки в 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
  );
};

Что изменилось:

  1. Убрали управление состояниями animation и view

  2. Заменили на деструктуризацию хука: const { shouldRender, animation, handleAnimationEnd } = useAnimation(open);

  3. Упростили логику - хук берет на себя всю работу с анимациями

ИТОГО: хватит гадать, когда закончится анимация.

История с setTimeout научила меня простой истине: не нужно пытаться угадать длительность анимации. Браузер уже знает, когда она завершится — благодаря onAnimationEnd.

Мой универсальный хук useAnimation решает главные проблемы:

  • Убирает конфликт состояний

  • Избавляет от магических чисел в коде

  • Работает с любыми CSS-анимациями

  • Выдерживает быстрое открытие/закрытие

Теперь все модалки проекта работают плавно и предсказуемо. А главное — этот подход масштабируется на любые анимированные компоненты.

P.S. (постскриптум)

Это моя первая публикация, буду рад любой критике. Спасибо, если дочитали до конца :-)

Комментарии (2)