Контроль рендеринга в React: Осторожное обращение с большими состояниями

Для каждого компонента React состояние — источник истины. Но когда useState или useReducer содержит крупные, вложенные структуры данных, непрозрачное поведение React может провоцировать дорогостоящие перерисовки. Рассмотрим практическую проблему и её системное решение.

Проблема непропорционального рендеринга

Представьте компонент EventScheduler, управляющий календарём событий. Его состояние — массив events из тысяч объектов:

javascript
const [events, setEvents] = useState([
  { id: 1, title: 'Meeting', date: '2023-10-10', attendees: [...] },
  // ...
]);

При обновлении одного события вы делаете:

javascript
setEvents(currentEvents => 
  currentEvents.map(event => 
    event.id === updatedId ? { ...event, title: newTitle } : event
  )
);

Логично: обновлён только один объект. Однако React по умолчанию перерисовывает все компоненты, зависящие от events. Если дочерний <EventItem event={event} /> используется как:

javascript
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.

Селекторы как решение

Суть: преобразуем крупное состояние перед передачей в компоненты. Используем строгий отбор данных и мемоизацию:

javascript
import { useMemo } from 'react';

function EventItem({ id }) {
  const event = useSelector(state => 
    state.events.find(event => event.id === id)
  );
  return <div>{event.title}</div>;
}

Родительский компонент теперь передаёт только id:

javascript
function EventList({ eventIds }) {
  return eventIds.map(id => <EventItem key={id} id={id} />);
}

Реализация селектора с учётом перерисовок

  1. Контекст для состояния:
    Оберните приложение в провайдер с состоянием events. Библиотеки (Redux, Zustand) дают готовую структуру, но возможна и vanilla реализация:
javascript
const EventsContext = createContext();

const EventsProvider = ({ children }) => {
  const [events, dispatch] = useReducer(eventsReducer, initialEvents);
  return (
    <EventsContext.Provider value={{ events, dispatch }}>
      {children}
    </EventsContext.Provider>
  );
};
  1. Хук useSelector:
    Сравнивает предыдущее и новое значение селектора через strict ===. Если неизменно — не инициирует рендер.
javascript
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 константна, поэтому мемоизация работает.

  1. Поддержка маппинга ID:
    В EventsProvider вычислите список ID один раз:
javascript
const eventIds = useMemo(() => events.map(e => e.id), [events]);

И передавайте через контекст только eventIds дочкам. При обновлении одного события:

  • events → новая ссылка массива
  • eventIds → та же ссылка ([1, 2, 3] эквивалентна при неизменных ID), рендера списка не будет.

Риски изменения данных напрямую

Селекторы — функции. При возврате изменяемого объекта возможны трудноотслеживаемые рендеры:

javascript
// Плохо: возвращаются изменяемые данные
useSelector(state => ({ events: state.events, status: state.loading }));

Решение: либо возврат примитивов, либо гарантированная неизменяемость с immer:

javascript
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 возвращает новые объекты
  • Контейнеры слушают изменения корневой ветки состояния
    Настройте селекторы на уровне подписчика:
javascript
// До: компонент получит { 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
  • Замеряйте влияние новой логики с помощью профайлера

Состояние — источник истины, но компоненты должны интересоваться только релевантными фрагментами данных. Достигайте подобной архитектуры через явное определение зависимостей каждого узла приложения — и производительность станет следствием дизайна, не результатом бесконечных оптимизаций.

text