Skip to main content

Keep-Alive Pattern for Tab Navigation

Summary

Архитектурное решение для предотвращения unmount/remount компонентов при переключении вкладок в React SPA.

Проблема: При переключении вкладок компоненты unmount → изображения выгружаются из памяти → при возврате появляется мерцание загрузки.

Решение: Keep-Alive pattern - все экраны остаются в DOM, переключаются через CSS display.


Business Context

Проблема пользователя

После долгого простоя приложения (5-10+ минут) при переключении между вкладками Home и Profile:

  • Изображение Steam логотипа "появляется внезапно" (мерцание)
  • Изображение Daily Case "появляется внезапно" (мерцание)
  • Другие изображения (scrap/xp иконки) не мерцают

Root Cause

  1. Условный рендеринг через {currentScreen}:

    // Было
    <main>{currentScreen}</main>
    • При activeTab=Home → рендерится только HomeScreen
    • При переключении на Profile → HomeScreen unmount, ProfileScreen mount
    • При возврате на Home → ProfileScreen unmount, HomeScreen mount заново
  2. Browser Memory Management:

    • При unmount компонента → DOM удаляется
    • После долгого простоя браузер выгружает изображения из memory cache
    • При mount заново → браузер запрашивает изображения с диска/сети
    • Пользователь видит момент загрузки (fade-in, мерцание)
  3. Почему только Steam и Case:

    • Находятся в разных unmount-able экранах (Profile, Home)
    • Scrap/XP иконки всегда в DOM (BalanceSection не unmount)

Technical Solution

Implementation

До (условный рендеринг):

const currentScreen = useMemo(() => {
switch (activeTab) {
case Tab.Home: return homeScreen;
case Tab.Profile: return profileScreen;
// ...
}
}, [activeTab, ...]);

return <main>{currentScreen}</main>; // ← UNMOUNT при переключении

После (Keep-Alive):

// Убрали currentScreen useMemo
// Рендерим все экраны, переключаем через display

return (
<main>
<div style={{ display: activeTab === Tab.Home ? 'block' : 'none' }}>
{homeScreen}
</div>
<div style={{ display: activeTab === Tab.Profile ? 'block' : 'none' }}>
{profileScreen}
</div>
<div style={{ display: activeTab === Tab.Cases ? 'block' : 'none' }}>
{casesScreen}
</div>
<div style={{ display: activeTab === Tab.Spin ? 'block' : 'none' }}>
{dailySpinScreen}
</div>
</main>
);

Преимущества

АспектДоПосле
UnmountДа, при переключенииНет, остаются в DOM
ИзображенияВыгружаются из памятиОстаются в кэше
СостояниеТеряется (scroll, forms)Сохраняется
ПереключениеRe-renderТолько CSS change
МерцаниеДа, после простояНет

Performance Impact

Memory:

  • 4 экрана в DOM вместо 1 (+3 скрытых)
  • Каждый экран: ~50-100 DOM nodes
  • Total overhead: ~200-300 nodes (минимально для современных браузеров)

CPU:

  • Нет лишних unmount/mount операций
  • Нет re-render при переключении
  • Только CSS display change (быстро)

Network:

  • React Query с staleTime предотвращает лишние refetch для скрытых экранов
  • Изображения загружаются 1 раз и остаются в памяти

ADR (Architectural Decision Record)

Контекст

React SPA с bottom navigation и 4 вкладками (Home, Profile, Cases, Spin).

Пользователи часто переключаются между вкладками и держат приложение открытым долго (15+ минут).

Решение

Выбран: Keep-Alive Pattern (все экраны в DOM, переключение через display)

Альтернативы (отклонены):

  1. Priority prop для LazyImage

    • Костыль, не решает root cause (unmount)
    • Не работает для простых <img> тегов
  2. CSS Preload Hint

    • Не решает unmount (могут быть другие проблемы)
    • Требует ручного управления списком изображений
  3. React.lazy с Suspense

    • Усугубляет проблему (ещё больше unmount)
    • Добавляет loading states

Последствия

Положительные:

  • ✅ Изображения больше не мерцают
  • ✅ Состояние компонентов сохраняется
  • ✅ Мгновенное переключение вкладок
  • ✅ Стандартный React паттерн (используется в Telegram Web, Discord, Slack)

Нейтральные:

  • 🔸 Все компоненты в DOM (но overhead минимальный)
  • 🔸 Нужно следить чтобы скрытые экраны не выполняли лишних операций

Отрицательные:

  • Нет значимых

Usage Guidelines

Когда использовать Keep-Alive

Используй для:

  • SPA с bottom/top navigation и несколькими вкладками (2-10)
  • Экраны с дорогими операциями (сложные формы, charts)
  • Экраны с изображениями/медиа контентом
  • Экраны где важно сохранить состояние (scroll, filters)

Не используй для:

  • Модальных окон (используй conditional rendering)
  • Deep navigation (используй React Router)
  • Большого количества экранов (>10, memory pressure)

Best Practices

  1. Оптимизация скрытых экранов:

    useEffect(() => {
    if (activeTab !== Tab.Home) return; // Пропустить если скрыт
    // Выполнить операцию только для активной вкладки
    }, [activeTab]);
  2. React Query staleTime:

    useQuery('key', fetcher, {
    staleTime: 5 * 60 * 1000, // 5 минут - не refetch при unmount
    });
  3. Мемоизация экранов:

    const homeScreen = useMemo(() => <HomeScreen />, []);
    // Предотвращает лишние re-renders