Головна / Статті

Стан легенди для дрібних оновлень React та React Native

Перемістіть показники гарячого стану до стану Легенди, щоб рядки плейлисту та таймери перерисовували лише ті елементи, які змінилися, а не всі піддрощі React Native.

Видавець: REACTAPP.TOP5 хв читання1202 слів

Джерела: Legend State: The Fastest React State Library You’re Probably Not Using

Оптимізація списків у React Native є знайомою проблемою продуктивності, і бібліотеки на кшталт Legend List дозволяють досягати швидкості прокрутки, близької до тієї, що має FlashList. Однак після достатньої кількості аудитів у реальних умовах віртуалізація списків більше не є основним обмеженням. Часто більш серйозною проблемою є стан: де він знаходиться у структурі та скільки компонентів переробляється при зміні одного поля.

Вбудовані хуки (useState, useContext) зручні у використанні, але часто не сумісні з принципом „менше переробки, менші розміри“. Багато зовнішніх механізмів зберігання допомагають покращити ситуацію. Legend State, який зараз знаходиться у бета-версії 3.0, використовує особливо деталізований підхід — розділяючи створення стану та підписку на зміни — і його детально продемонстрували на Chain React 2026.

Проблема з useState

Основна ідея цієї бібліотеки проста: швидші додатки зменшують обсяг роботи React та React Native — менше операцій з обробкою даних, менше перерендерингів. Стандартні хуки useState та useContext заважають досягненню цієї мети, оскільки наявність змінної вимагає від компонента, який нею керує (або від кожного користувача контексту), її оновлення.

Розгляньмо екран чату, який зберігає значення replyId у локальному стані:

function ChatScreen() {
  const [replyId, setReplyId] = useState('')

  return (
    <View>
      <ChatMessages replyId={replyId} />
      <ChatComposer replyId={replyId} />
    </View>
  )
}

function ChatMessage({ id, replyId }) {
  const isReply = id === replyId

  // ...
}

Сам ChatScreen може не потребувати перерендерингу, коли змінюється replyId — лише дочірні елементи, які читають це значення. Однак за наявності useState у батьківському компоненті React все одно перерендерує ChatScreen, оскільки змінилась величина хука. Мемоїзація та компілятор React не можуть уникнути цієї процедури: оновлення стану є реальним, тому компонент перерендерується, а потім відбувається процес узгодження навіть тоді, коли більшість параметрів залишилися незмінними.

useContext іноді здається виходом із складної ситуації, але насправді він призводить до широкого розповсюдження змін, а не до глибокого: кожен користувач контексту переробляється щоразу, коли змінюється будь-яка його частина, навіть якщо цей користувач ніколи не читає змінене поле. Розділення на багато дрібних постачальників допомагає, поки структура не набуває такого знайомого формату вкладеності:

function App() {
  return (
    <SomeProvider>
      <SomeOtherProvider>
        <YetAnotherProvider>
          <ThisWillBeTheLastOneProvider>
            <OkJustOneMoreProvider>
              <FinalFinalProvider>
                <SendHelpProvider>
                  <ActualApp />
                </SendHelpProvider>
              </FinalFinalProvider>
            </OkJustOneMoreProvider>
          </ThisWillBeTheLastOneProvider>
        </YetAnotherProvider>
      </SomeOtherProvider>
    </SomeProvider>
  )
}

Наскільки серйозною є ця проблема насправді?

Достатньо серйозною, щоб її можна було виміряти. Один тест порівняв три способи розміщення таймера музичного додатку, який відлічує час чотири рази на секунду: у корені додатку, посередині структури та лише у компоненті тексту:

// App root
function App() {
  const [time, setTime] = useState(0)

  return (
    <Window>
      <PlaybackArea time={time} />
      <Playlist />
      <BottomToolbar />
    </Window>
  )
}

// Playback area:
function PlaybackArea() {
  const [time, setTime] = useState(0)

  return (
    <View>
      <PlaybackTime time={time} />
      <TheRestOfThePlaybackArea />
    </View>
  )
}

// Leaf text:
function ElapsedText() {
  const [time, setTime] = useState(0)

  return (
    <Text>{time}</Text>
  )
}

Під час цього тестування таймер, який знаходився у корені, працював приблизно у десять разів повільніше, ніж версія, розташована у компоненті тексту. Розміщення часто оновлюваних даних — це не дрібна оптимізація; воно має вирішальне значення, коли оновлення відбуваються часто.

Що робить Legend State унікальним?

Приклад списку відтворення наглядно демонструє різницю: фон рядка треку має змінюватися, коли цей трек стає активним. Версія з useState часто виглядає так:

function MusicApp() {
  const [playback, setPlayback] = useState(initialPlayback);

  return <TrackList activeTrackId={playback.activeTrackId} />;
}

function TrackList({ activeTrackId }) {
  return tracks.map((track) => (
    <TrackRowWrapper
      key={track.id}
      track={track}
      activeTrackId={activeTrackId}
    />
  ));
}

function TrackRowWrapper({ track, activeTrackId }) {
  const isActive = activeTrackId === track.id;

  return <TrackRowView track={track} isActive={isActive} />;
}

Команди намагаються зменшити ці витрати, обчислюючи булеве значення isActive у обгортці та передаючи його далі, щоб TrackRowView, який використовує мемоізацію, міг пропустити оновлення, якщо прапорець залишився незмінним. Це допомагає, проте кожен виклик setPlayback все одно змушує переробити MusicApp, TrackList та всі обгортки в списку — навіть якщо насправді оновити потрібно лише два варіанти відображення рядків (старий активний та новий активний).

Відповідь Legend State полягає у тому, що такий тип стану не повинен знаходитися в React. Ви створюєте observable (іноді його називають сигналом). Створення магазину даних та підписка на нього — це окремі кроки:

// creates the state
const playback$ = observable({
  tracks: [],
  activeTrackId: null,
});

function TrackRow({ track }) {
  const isActive = useValue(
    // subscribes *only* to playback$.activeTrackId
    () => playback$.activeTrackId.get() === track.id,
  );

  return (
    <View style={[styles.row, isActive && styles.activeRow]}>
      <Text>{track.title}</Text>
    </View>
  );
}

Закінчення $ є лише звичкою найменування, яка означає „спостережуване“; воно не має жодних особливих можливостей під час виконання. Об’єкт playback$ залишається стабільним з точки зору посилань. Вкладені поля змінюються за допомогою set() або assign(), але ідентичність спостережуваного об’єкта не змінюється.

// ...

const isActive = useValue(
    () => playback$.activeTrackId.get() === track.id,
  );

// ...

Коли змінюється activeTrackId, селектор виконується знову. Переробка відображення заплановується лише у разі зміни булевого результату. У списку відтворення з 500 треків усі 500 селекторів можуть бути переоцінені за допомогою простих порівнянь рядків поза React; для 498 треків результат залишається незмінним, тому React ніколи не отримує сповіщення. Переробляються лише попередній та наступний активні елементи.

Ще більш детальний підхід

Навіть це може вимагати більше зусиль, ніж потребує «гарячий шлях». Зміна кольору фону все одно змушує переробити весь TrackRow. Legend State постачає реактивні примітиви (з назвами, що починаються з $) , які приймають реактивні параметри:

function TrackRow({ track }) {
  return (
    <$View
      $style={() => [
        styles.row,
        playback$.activeTrackId.get() === track.id && styles.activeRow,
      ]}
    />
  );
}

TrackRow монтується один раз та не виконує свій код знову під час подальших оновлень. Коли змінюється activeTrackId, виконується лише функція $style у маленькому обгортку, який додає бібліотека. Компонент перестає бути «функцією, яка знову виконується при зміні даних», і стає «структурою, налаштованою один раз, з невеликими реактивними елементами, які самостійно оновлюються».

Синтаксис високої продуктивності може здатися незнайомим. У повсякденному використанні застосовується патерн observable-plus-useValue з попереднього розділу, який і так є швидким; компоненти $ є вихідним рішенням для проблемних «гарячих точок».

Продовження

Ця сама архітектура дозволяє робити більше, ніж просто обрізку результатів відображення:

  • useObserveEffect реагує на зміни стану, а не на цикли відображення, що усуває багато крихких масивів залежностей, які стимулює useEffect.
  • Оновлення містять нове значення, попереднє значення та шлях, який змінився — достатньо деталей для підтримки функцій скасування/повторення дій над магазином даних.
  • Ці детальні записи змін також забезпечують роботу двигуна синхронізації з пріоритетом офлайн-режиму разом із плагінами серверної частини; вони входять до бібліотеки з відносно невеликою кількістю коду налаштування (це тема, яка сама по собі потребує окремої розмови).

Для команд, які вже шукають альтернативи useState, Legend State заслуговує уважної уваги, якщо детальне керування підписками та функціональність опційних реактивних примітивів мають більше значення, ніж відповідність найпопулярнішій структурі API. Почніть з observables та селективних хуків; використовуйте компоненти з префіксом $ лише після того, як аналіз покаже, що певний елемент все ще оновлюється занадто часто. Такий підхід зберігає простоту розуміння моделі, водночас залишаючи можливості для оптимізації найбільш використовуваних шляхів у інтерфейсі React Native.