Главная / Статьи

Элементы состояния «легенда» для тонкой настройки обновлений в 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. Начните с обзерваблей и селективных хуков; обращайтесь к компонентам с префиксом $ только после того, как анализ покажет, что какой-то ряд или ячейка все еще отрисовывается слишком часто. Такой подход сохраняет простоту понимания, оставляя при этом возможности для оптимизации самых загруженных участков интерфейса React Native.