Раздел 7 · Браузер и Frontend

React: контекст, производительность и Suspense

middle-senior~60 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

React: контекст, производительность и Suspense

К этому уроку Echo собран из компонентов (урок 05) и оживлён хуками (урок 06). Теперь три задачи, которые встают в любом растущем приложении. Как раздать общие данные (тему, текущего пользователя) вглубь дерева, не прокидывая пропсы через десять уровней. Как не дать одной ошибке в рендере уронить весь экран. И как держать интерфейс быстрым, когда сообщений становятся тысячи. У каждого инструмента здесь есть две стороны: где он помогает и где, наоборот, делает хуже. Эту вторую сторону я буду подчёркивать, потому что именно на ней спотыкаются.

1. Контекст: общий стейт без прокидывания пропсов

Тема оформления нужна и шапке, и боковой панели, и каждому сообщению. Тащить theme пропсом через App, ChannelView, MessageList, Message это прокидывание пропсов: скучно и хрупко. Для такого есть контекст.

Создаём контекст, оборачиваем дерево провайдером, читаем в потомке:

// apps/echo-react-rtk/src/contexts/ThemeContext.tsx
import { createContext, useContext, useState } from 'react';

type Theme = 'light' | 'dark';
type ThemeValue = { theme: Theme; toggle: () => void };

const ThemeContext = createContext<ThemeValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('dark');
  const toggle = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
  return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
}

export function useTheme(): ThemeValue {
  const value = useContext(ThemeContext);
  if (value === null) throw new Error('useTheme вызван вне ThemeProvider');
  return value;
}

Любой компонент внутри ThemeProvider зовёт useTheme() и получает тему без единого пропса. Проверка на null плюс свой хук это удобный приём: он ловит ошибку “забыл обернуть в провайдер” сразу и понятным сообщением.

2. Цена контекста и как её снизить

У контекста есть ценник, и про него молчат гайды для новичков. Представь контекст как шумную радиостанцию: когда значение меняется, все подписчики (useContext) перерисовываются, даже те, кому интересна совсем другая часть значения.

Дальше тонкость. Значение мы передаём объектом { theme, toggle }. Если писать его инлайном, как выше, то на каждый рендер провайдера это новый объект (новая ссылка), и все подписчики перерисовываются, даже когда тема не менялась. Лечится мемоизацией значения:

const value = useMemo(() => ({ theme, toggle }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

Вторая частая оптимизация это разделить контекст на данные и действия. Сеттеры (toggle, dispatch) обычно стабильны и меняются редко, а данные меняются часто. Если держать их в одном контексте, компонент, которому нужна только кнопка переключения, перерисовывается на каждое изменение данных зря. Разнеси на два контекста:

const ThemeStateContext = createContext<Theme | null>(null);
const ThemeActionsContext = createContext<(() => void) | null>(null);

Тогда компонент-кнопка читает только ThemeActionsContext и молчит, пока данные скачут. Это твоё домашнее задание hw-fe-07-split-context. Когда нужна совсем тонкая подписка (читать одно поле из большого стора и перерисовываться только на него), смотри в сторону useSyncExternalStore: на нём построены useSelector в Redux (урок 10) и подписки в Zustand.

3. Error Boundary: ловим падения рендера

Один компонент бросил исключение в рендере, и без защиты падает весь экран: React размонтирует всё дерево, пользователь видит белый лист. Чтобы локализовать падение, ставят границу ошибок. Это тот редкий случай, когда без классового компонента не обойтись: у хуков аналога пока нет.

// apps/echo-react-rtk/src/components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';

type Props = { fallback: ReactNode; children: ReactNode };
type State = { hasError: boolean };

export class ErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false };

  static getDerivedStateFromError(): State {
    return { hasError: true }; // переключаем на fallback при ошибке
  }

  componentDidCatch(error: unknown) {
    console.error('Поймано границей ошибок:', error); // сюда же отправка в Sentry
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

Оборачиваешь рискованный кусок, и его падение не уносит соседей:

<ErrorBoundary fallback={<p>Сообщения не загрузились. Обнови страницу.</p>}>
  <MessageList messages={messages} />
</ErrorBoundary>

Важно помнить, чего граница не ловит: ошибки в обработчиках событий (там обычный try/catch), в асинхронном коде (промис ловишь сам) и при серверном рендере. В реальном проекте берут готовую библиотеку react-error-boundary: тот же класс внутри, но с хуком сброса и удобной обёрткой. Написать свою границу и обёртку withErrorBoundary твоё задание hw-fe-07-error-boundary.

4. Что вызывает лишний рендер и как его убрать

В уроке 05 мы выяснили: когда рендерится родитель, по умолчанию рендерятся все его потомки. Обычно это дёшево. Но когда рендер потомка тяжёлый или потомков тысячи, лишние перерисовки начинают стоить кадров.

Покрути это вживую. В App лежит несвязанное состояние, кнопка tick его меняет и заставляет App перерисоваться. Смотри, кто из детей перерисовался следом и почему.

Что показывает виджет:

  • без memo все дети перерисовываются на каждый tick, хотя их данные не менялись. Причина у каждого одна: родитель перерисовался;
  • включаешь memo, и Toolbar с MessageList замолкают: их пропсы по значению те же, React.memo пропускает рендер;
  • а SendButton всё равно рендерится. Потому что ему передают onSend={() => ...}, то есть новую функцию на каждом рендере, и для memo это новый проп;
  • включаешь useCallback для onSend, ссылка стабилизируется, и SendButton тоже замолкает.

Разложим инструменты:

React.memo останавливает каскад рендеров вниз, если пропсы стабильны. useMemo и useCallback дают стабильные ссылки, без которых memo не работает. Они ходят парой: memo на компоненте плюс useMemo/useCallback на объектах и функциях, которые в него летят.

Но прежде чем тянуться за memo, прочитай заметку Дэна Абрамова “Before You memo()”. Часто лишний рендер убирается дешевле и без мемоизации: опусти состояние ниже, чтобы рендер не шёл от корня, или передай тяжёлого потомка через children, тогда смена состояния родителя его не задевает. memo это инструмент после профайлинга, а не привычка обмазывать им всё подряд: каждый memo это сравнение пропсов на каждом рендере, и на дешёвых компонентах он сам по себе расход.

5. React Compiler: автоматическая мемоизация

Раздел 4 заканчивается грустной оговоркой: чтобы React.memo сработал, нужно ещё расставить useMemo и useCallback на пропсах, иначе ссылки будут новые. В реальном коде это превращается в плотный лес ручных оптимизаций, по которому быстро устаёшь ходить, и который к тому же легко испортить: забыл одну зависимость, и мемо разваливается тихо.

Так подсказывать React руками умели с самого начала, просто инструмент был другой. В эпоху классовых компонентов писали метод shouldComponentUpdate: он сравнивал старые и новые пропсы и возвращал false, чтобы React пропустил рендер. React.memo это его прямой потомок для функций, только поверхностное сравнение пропсов зашито внутрь. Подход у обоих один: разработчик руками показывает React, где не делать лишнего, и платит за это лишним кодом и риском ошибиться в сравнении.

Команда React закрывает эту боль на уровне сборки. React Compiler: плагин для Babel и SWC, который читает твой код на этапе сборки и сам заворачивает выражения в кэш по входам. Получается то же, что ты бы расставил руками, только без леса хуков и без риска забыть зависимость.

Подключение в Vite это одна строка в плагине React:

// vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    react({
      babel: { plugins: [['babel-plugin-react-compiler', { target: '19' }]] },
    }),
  ],
});

Дальше ты просто пишешь компоненты как в уроке 04, без memo, без useMemo, без useCallback:

function ChannelHeader({ channel, messages }: Props) {
  const unread = messages.filter((m) => !m.read).length; // компилятор сам закэширует
  const onPin = () => pinChannel(channel.id); // ссылку компилятор тоже стабилизирует
  return (
    <header>
      <h2>{channel.name}</h2>
      <UnreadBadge count={unread} onPin={onPin} />
    </header>
  );
}

На выходе компилятор примерно повторит тот же эффект, что и ручная тройка memo+useMemo+useCallback из раздела 4, и обновит кэш только тогда, когда поменялся какой-то из входов.

Что важно держать в голове, чтобы компилятор работал, а не выключался:

  • Он опирается на Правила React: чистый рендер, иммутабельные пропсы, хуки строго наверху. Включай eslint-plugin-react-hooks и плагин компилятора, они подсветят нарушения до сборки.
  • Если функция нарушает правила (мутирует входной массив, читает ref на верхнем уровне, делает побочный эффект в рендере), компилятор тихо её пропустит. Без линтера ты не заметишь, что половина приложения оптимизируется, а половина нет.
  • Это не серебряная пуля. Компилятор убирает ненужный рендер, но не спасёт от пяти тысяч живых DOM-узлов в ленте сообщений: тут всё ещё нужна виртуализация, которой мы сейчас и займёмся.
  • Понимать модель из раздела 4 (что вызывает рендер, что такое стабильная ссылка) всё равно надо. Компилятор автоматизирует применение, а не освобождает от понимания: на собеседовании и в профайлере ты по-прежнему смотришь на ссылки и каскад.
  • Компилятор оптимизирует только функциональные компоненты, классовый он просто не трогает. Классы в React официально ещё поддерживаются, но новых возможностей под них уже не делают, для них это легаси. Живое применение классов у нас осталось одно, это Error Boundary из раздела 3, у которой пока нет хуковой замены. Весь остальной код пиши функциями.

Дальше в уроке я оставляю ручные memo и useMemo для дидактики, чтобы ты увидел механизм своими глазами. В реальном Echo на конец серии оптимизация компонентов автоматическая, и руками мы трогаем только то, что компилятор пропустил: тяжёлые чистые вычисления через useMemo и редкие случаи, когда профайлер всё-таки указал в одно место.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.