Элементы состояния «легенда» для тонкой настройки обновлений в React и React Native.
Перенесите элементы состояния «горячий режим» в элементы состояния «легенда», чтобы строки плейлиста и таймеры перерисовывали только изменившиеся ячейки, а не целые поддеревья React Native.
Источники: 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.