Стриминговые приложения почти всегда состоят не из одного экрана: рядом с видео живут чат, заметки, расшифровка, список похожих роликов и другие панели. Если при переключении между ними состояние интерфейса не сохраняется, навигация быстро начинает раздражать.
Представьте: пользователь скрывает видеоплеер, а когда открывает его снова, воспроизведение начинается с самого начала. Именно такие мелочи заставляют думать, что посмотреть видео где-нибудь в другом месте было бы проще.
Если приложение написано на React, причина обычно в том, что при скрытии компонента React размонтирует его — а вместе с ним уничтожает и всё внутреннее состояние.
В этой статье разберём три сценария и шаг за шагом придём к решению этой проблемы с помощью нового компонента Activity из React 19.2 и Mux Player. По пути посмотрим на подводные камни и в конце получим самый аккуратный вариант из тех, что React сейчас предлагает «из коробки».
React раньше
До React 19.2 показ и скрытие компонентов чаще всего делали через условный рендеринг:
{isVideoTab ? <VideoPlayer /> : null}Когда компонент видеоплеера скрывается, он размонтируется. Когда пользователь возвращается назад, React монтирует новый экземпляр, и видео снова запускается с начала. Весь прогресс воспроизведения теряется.
Для стриминговых приложений это откровенно плохой UX. Это всё равно что поставить на паузу свежую серию любимого сериала, свернуть плеер, а потом вернуться и обнаружить, что серия снова идёт с первой секунды.
Покажи, что умеет <Activity>
Компонент Activity предлагает новый способ управлять видимостью в React. Вместо того чтобы монтировать и размонтировать компоненты, он оставляет их в DOM и переключает только их состояние видимости.
У компонента есть проп mode с двумя значениями:
visible— компонент виден и с ним можно взаимодействоватьhidden— компонент остаётся смонтированным, но становится невидимым
Базовый пример выглядит так:
<Activity mode={activeTab === "video" ? "visible" : "hidden"}>
<VideoPlayer />
</Activity>В примерах ниже переключение происходит через вкладки и кнопки. Главное здесь в том, что Activity не даёт видеоплееру размонтироваться при смене вкладок. А значит, сохраняются:
- текущая позиция воспроизведения
- уже загруженный буфер
- громкость
- и всё остальное внутреннее состояние плеера
Три сценария: от проблемы к решению
Теперь — к практике. Ниже три реализации, каждая из которых исправляет недостатки предыдущей.
Сценарий 1. Классический условный рендеринг
Это самый прямолинейный вариант: обычный conditional rendering.
{isVideoTab ? (
<div>
<Player autoPlay muted />
</div>
) : (
<div>
<NotesPanel />
</div>
)}Когда пользователь переключается с вкладки видео на заметки, React полностью размонтирует компонент <Player>. Когда он возвращается назад, создаётся новый экземпляр плеера, и воспроизведение начинается заново.
Итог простой: включил видео, ушёл на вкладку с заметками, вернулся — и снова видишь 0:00. Весь прогресс потерян.
С технической точки зрения здесь всё предсказуемо: нет обёртки Activity, значит, React уничтожает компонент при каждом переключении и каждый раз создаёт его заново. В стандартном жизненном цикле React нет встроенного механизма «заморозить» компонент, а потом вернуть его с прежним состоянием.
Сценарий 2. Activity, но без паузы
Здесь уже лучше: мы используем Activity, но этого всё ещё недостаточно.
<Activity mode={isVideoTab ? "visible" : "hidden"}>
<div>
<Player autoPlay muted />
</div>
</Activity>
<Activity mode={!isVideoTab ? "visible" : "hidden"}>
<div>
<NotesPanel />
</div>
</Activity>Теперь плеер не размонтируется, когда становится невидимым, поэтому позиция воспроизведения сохраняется. Но появляется другой нюанс: видео продолжает играть в фоне. Пользователь открывает вкладку заметок, а звук из скрытого видео всё ещё идёт.
Это не обязательно ошибка — для некоторых интерфейсов такое поведение может быть уместным. Но чаще хочется другого: сохранить состояние и при этом автоматически ставить видео на паузу.
Важно понимать, что Activity скрывает содержимое через display: none, но не останавливает JavaScript и не управляет воспроизведением медиа. Видео остаётся в DOM, поэтому при возврате пользователь попадает ровно туда, где остановился. Просто никто не сказал плееру, что при скрытии надо поставить воспроизведение на паузу.
Сценарий 3. Activity с автопаузой — полноценное решение
Чтобы довести решение до ума, нужно объединить Activity с хуком useLayoutEffect, который будет ставить плеер на паузу в момент скрытия.
const PausingPlayerPanel = ({ isVisible, idPrefix }: PausingPlayerPanelProps) => {
const playerRef = useRef<MuxPlayerElement | null>(null);
useLayoutEffect(() => {
const player = playerRef.current;
return () => {
player?.pause();
};
}, [isVisible]);
return (
<div hidden={!isVisible}>
<Player autoPlay muted ref={playerRef} />
</div>
);
};Так это оборачивается в Activity:
<Activity mode={isVideoTab ? "visible" : "hidden"}>
<PausingPlayerPanel isVisible={isVideoTab} idPrefix="finished" />
</Activity>Когда isVisible меняется с true на false, срабатывает cleanup-функция эффекта и ставит видео на паузу ещё до того, как пользователь увидит переключение вкладки. При этом Activity не даёт компоненту размонтироваться, поэтому позиция паузы сохраняется. Когда пользователь возвращается, он попадает ровно в ту точку, где остановился.
То есть поведение становится именно таким, каким его обычно ожидают:
- переключился на заметки — видео сразу и беззвучно остановилось
- вернулся назад — продолжил с того же места
Здесь критично использовать именно useLayoutEffect, а не useEffect. useLayoutEffect выполняется синхронно до перерисовки браузером, поэтому пауза успевает сработать до того, как пользователь заметит переход. С useEffect был бы шанс на короткий «хвост» звука из уже скрытой вкладки.
В результате получается лучшее из двух миров:
- сохранение состояния благодаря
Activity - предсказуемое поведение воспроизведения благодаря эффекту
Не забудьте про refs
Компонент <MuxPlayer> в примере — это тонкая обёртка над @mux/mux-player-react, которая пробрасывает ref дальше вниз и задаёт несколько значений по умолчанию:
const Player = forwardRef<MuxPlayerElement, PlayerProps>(
({ playbackId = DEFAULT_PLAYBACK_ID, streamType = "on-demand", ...rest }, ref) => (
<MuxPlayer ref={ref} playbackId={playbackId} {...rest} />
)
);Проброс ref здесь принципиален. Благодаря ему родительский компонент получает доступ к реальному видеоэлементу и может напрямую вызывать методы вроде pause(). Без этого useLayoutEffect просто не смог бы управлять плеером.
Коротко повторим
Во всех трёх примерах используется один и тот же компонент <Player>, но по-разному устроено управление состоянием:
- UnmountedExample — условный рендеринг без
Activity - HiddenButPlayingExample — только обёртка
Activity - FinishedExample —
Activity+useLayoutEffectс логикой паузы
Паттерн с Activity элегантно решает задачу сохранения состояния и при этом остаётся читаемым. И полезен он не только для видеоплееров. Точно так же его можно применять, например, в таких сценариях:
- формы с несохранёнными данными — чтобы не терять введённый текст при переключении вкладок
- таблицы с фильтрами — чтобы сохранять сортировку, фильтры и позицию скролла
- рисовалки и canvas-интерфейсы — чтобы не терять сложное внутреннее состояние
- музыкальные плееры — чтобы управлять воспроизведением независимо от навигации по приложению
Чеклист для старта
Если хочешь применить этот подход в своём видеоприложении, вот что понадобится:
- Обновиться до React 19.2, чтобы получить доступ к
Activity - Обернуть видеоплеер в
Activityи переключатьmodeв зависимости от активной вкладки - Пробросить
refиз компонента плеера, чтобы родитель мог управлять видеоэлементом - Добавить
useLayoutEffectс логикой паузы, срабатывающей при изменении видимости
Итог
Компонент Activity в React заметно упрощает создание интерфейсов со сложной навигацией и живым состоянием. Для стриминговых сервисов, образовательных платформ и вообще любых экранов с медиа это действительно важное улучшение.
Если тебе нужно просто скрывать и показывать части интерфейса без потери состояния, Activity уже даёт очень многое. А если добавить к нему небольшой слой логики через useLayoutEffect, можно получить почти идеальное поведение для видеоплеера: без фонового воспроизведения и без потери позиции.
Ресурсы
- Demo repo: https://github.com/muxinc/Mux-React-Activity
- Документация React по Activity: https://react.dev/reference/react/Activity
- Документация Mux Player: https://docs.mux.com/guides/mux-player-web
Теги: react, streaming, video