Для каждого компонента React состояние — источник истины. Но когда useState
или useReducer
содержит крупные, вложенные структуры данных, непрозрачное поведение React может провоцировать дорогостоящие перерисовки. Рассмотрим практическую проблему и её системное решение.
Проблема непропорционального рендеринга
Представьте компонент EventScheduler
, управляющий календарём событий. Его состояние — массив events
из тысяч объектов:
const [events, setEvents] = useState([
{ id: 1, title: 'Meeting', date: '2023-10-10', attendees: [...] },
// ...
]);
При обновлении одного события вы делаете:
setEvents(currentEvents =>
currentEvents.map(event =>
event.id === updatedId ? { ...event, title: newTitle } : event
)
);
Логично: обновлён только один объект. Однако React по умолчанию перерисовывает все компоненты, зависящие от events
. Если дочерний <EventItem event={event} />
используется как:
function EventList({ events }) {
return events.map(event => <EventItem key={event.id} event={event} />);
}
— после setEvents
каждый EventItem
выполнит React.memo(...)
сравнение свойств. При 10 000 элементов сравнение само по себе заберет >15мс, вызвав видимые лаги интерфейса.
Почему так происходит
React синхронно вычисляет новый Virtual DOM после изменения состояния. Он не знает, какая часть состояния изменилась — лишь то, что ссылка на events
новая. Следовательно:
EventList
получает новыйevents
(новая ссылка массива)- Каждый
EventItem
получает объектevent
. Даже если данные идентичны — ссылка на объект меняется, что аннулируетmemo
.
Селекторы как решение
Суть: преобразуем крупное состояние перед передачей в компоненты. Используем строгий отбор данных и мемоизацию:
import { useMemo } from 'react';
function EventItem({ id }) {
const event = useSelector(state =>
state.events.find(event => event.id === id)
);
return <div>{event.title}</div>;
}
Родительский компонент теперь передаёт только id
:
function EventList({ eventIds }) {
return eventIds.map(id => <EventItem key={id} id={id} />);
}
Реализация селектора с учётом перерисовок
- Контекст для состояния:
Оберните приложение в провайдер с состояниемevents
. Библиотеки (Redux, Zustand) дают готовую структуру, но возможна и vanilla реализация:
const EventsContext = createContext();
const EventsProvider = ({ children }) => {
const [events, dispatch] = useReducer(eventsReducer, initialEvents);
return (
<EventsContext.Provider value={{ events, dispatch }}>
{children}
</EventsContext.Provider>
);
};
- Хук
useSelector
:
Сравнивает предыдущее и новое значение селектора через strict===
. Если неизменно — не инициирует рендер.
import { useContext, useRef, useMemo } from 'react';
function useSelector(selector) {
const { events } = useContext(EventsContext);
const prevValueRef = useRef();
const currentValue = useMemo(
() => selector(events),
[events, selector]
);
return currentValue;
}
Критичен аргумент [events, selector]
. При изменении events
ссылка на selector
константна, поэтому мемоизация работает.
- Поддержка маппинга ID:
ВEventsProvider
вычислите список ID один раз:
const eventIds = useMemo(() => events.map(e => e.id), [events]);
И передавайте через контекст только eventIds
дочкам. При обновлении одного события:
events
→ новая ссылка массиваeventIds
→ та же ссылка ([1, 2, 3]
эквивалентна при неизменных ID), рендера списка не будет.
Риски изменения данных напрямую
Селекторы — функции. При возврате изменяемого объекта возможны трудноотслеживаемые рендеры:
// Плохо: возвращаются изменяемые данные
useSelector(state => ({ events: state.events, status: state.loading }));
Решение: либо возврат примитивов, либо гарантированная неизменяемость с immer
:
import { produce } from 'immer';
const safeEventsSelector = (state) =>
produce(state.events, draft => {
// Операции над черновиком
draft[0].title = 'Immutable Update';
});
Анализируйте рендеры в DevTools
React Developer Tools > Profiler:
- Запишите профиль после обновления
- Пометьте компоненты через
React.memo(Component, isEqual)
- Ищите не нуждавшиеся в рендер, но перерисованные
Если EventItem
при обновлении события №7 перерисовывались со всеми → нужны селекторы.
Интеграция с существующим стейтом
Типичное заблуждение: «У меня Redux — проблем нет». Но даже в Redux излишние рендеры встречаются, если:
useSelector
возвращает новые объекты- Контейнеры слушают изменения корневой ветки состояния
Настройте селекторы на уровне подписчика:
// До: компонент получит { userDetails, orders } — весь объект state.user
const { userDetails, orders } = useSelector(state => state.user);
// После: разделяйте подписки
const userDetails = useSelector(state => state.user.userDetails);
const orders = useSelector(state => state.user.orders);
Заключение: Обновляйте, не пересоздавая
Избежать массовых перерисовок значимых по объёму интерфейсов — обязанность разработчика. Осознавайте, какая часть состояния действительно нужна каждому компоненту. Намеренно внедряйте селекторы:
- Извлекайте в компонентах минимально необходимые фрагменты данных
- Сравнивайте сериализованное состояние при
memo
- Замеряйте влияние новой логики с помощью профайлера
Состояние — источник истины, но компоненты должны интересоваться только релевантными фрагментами данных. Достигайте подобной архитектуры через явное определение зависимостей каждого узла приложения — и производительность станет следствием дизайна, не результатом бесконечных оптимизаций.