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.