Узнайте, как неправильное размещение состояния в React вызывает лаги при вводе. Простое решение без useMemo — переместите useState ниже. Попробуйте прямо сейчас!
Одно поле поиска сломало весь мой React-дашборд. Каждое нажатие клавиши запаздывало почти на 300 мс. Моя первая мысль — мемоизировать всё: useMemo, useCallback, обернуть половину приложения в React.memo. Это была неправильная мысль.
Приложение было не огромным: около 150 компонентов — боковая панель, шапка, таблица, графики, фильтры. Но когда я печатал в поиске, ввод отставал на 300 мс. React DevTools показали, что одно нажатие клавиши перерисовывало весь экран: поиск, таблицу, шапку, боковую панель, графики и даже подвал.
Вот упрощённая версия того, что было:
import { useState } from 'react';
export default function Dashboard() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div className="layout">
<Header />
<Sidebar />
<main>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<DataGrid query={searchQuery} />
<HeavyCharts />
<Footer />
</main>
</div>
);
}На первый взгляд не страшно. Состояние поиска принадлежит странице, верно? Это была ловушка. Состояние жило в Dashboard, поэтому каждое изменение поиска обновляло Dashboard. React рендерит компонент и всех его детей, если не сработает оптимизация. Так каждое нажатие клавиши заставляло весь дашборд участвовать в рендере.
Профилировщик до исправления показывал 142.7 мс на нажатие. Для плавных 60 FPS нужно около 16 мс на кадр — мой поиск тратил почти 9 кадров на рендер. Неудивительно, что было медленно.
В Python вы быстро учитесь не выносить переменные в более широкую область видимости, чем нужно. Если переменная используется только в одной функции, держите её внутри. Если сделать её модульной «на всякий случай», получите путаницу. Состояние React имеет тот же запах. useState внутри Dashboard — не буквально глобальная переменная Python, но ведёт себя как значение с избыточным охватом.
Я задал простой вопрос: кому на самом деле нужен searchQuery? Ответ: только SearchBar и DataGrid.
Я переместил состояние в компонент, который содержит только поиск и таблицу:
import { useState } from 'react';
function SearchableDataGrid() {
const [searchQuery, setSearchQuery] = useState('');
return (
<>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<DataGrid query={searchQuery} />
</>
);
}
export default function Dashboard() {
return (
<div className="layout">
<Header />
<Sidebar />
<main>
<SearchableDataGrid />
<HeavyCharts />
<Footer />
</main>
</div>
);
}Вот и всё изменение. Dashboard снова стал просто разметкой. SearchableDataGrid стал владельцем шумного состояния. Теперь нажатие клавиши рендерит только SearchableDataGrid, SearchBar и DataGrid. Шапка, боковая панель, графики и подвал не перерисовываются.
Время рендера упало с 142.7 мс до 4.1 мс на нажатие. Цифры с моей машины, но суть ясна: работа по рендеру перестала распространяться по всей странице.
React.memo, useMemo и useCallback — хорошие инструменты, но я тянулся к ним слишком рано. Мемоизация помогает пропустить работу, если входные данные не изменились, но добавляет сложность. Чистое исправление — вообще не запускать эти компоненты.
Теперь я отлаживаю производительность React в таком порядке:
Этот порядок спасает от украшения плохой структуры умными хуками.
Если сама таблица дорогая, перемещение состояния не спасёт. Например, если DataGrid фильтрует 10 000 строк на каждое нажатие, поможет useDeferredValue:
import { useDeferredValue, useState } from 'react';
function SearchableDataGrid() {
const [searchQuery, setSearchQuery] = useState('');
const deferredQuery = useDeferredValue(searchQuery);
return (
<>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<DataGrid query={deferredQuery} />
</>
);
}Это сохраняет отзывчивость ввода, пока React обновляет тяжёлое представление с низким приоритетом.
Бывают случаи, когда состояние действительно должно жить выше. Если шапке нужно показывать «23 совпадения», а боковая панель должна синхронизироваться с тем же запросом, перемещение состояния вниз может усложнить поток данных. Тогда стоит поднять его обратно, использовать контекст для небольшой общей области или крошечное внешнее хранилище.
И да, иногда React.memo — правильный инструмент:
import React from 'react';
const HeavyCharts = React.memo(function HeavyCharts() {
// Дорогой рендер графиков, не зависящий от searchQuery.
return <Charts />;
});Но пусть это не будет вашим первым рефлексом.
Когда React-взаимодействие кажется медленным, я открываю Profiler до изменения кода. Записываю взаимодействие один раз. Затем спрашиваю:
Если вся страница загорается после одного нажатия, ищу состояние, сидящее слишком высоко в дереве. Если загорается только нужный компонент, но он всё равно работает 100 мс, смотрю внутрь него.
Исправление не было сложным. Не новая библиотека, не хитрый хук. Это была скучная ошибка области видимости. Я относился к строке поиска как к состоянию уровня страницы, потому что так было удобно. React сделал именно то, что я попросил, а я час винил рендеринг.
Версия урока на Python: если переменная принадлежит функции, не кладите её в модуль. Версия на React: если состояние принадлежит двум компонентам, не заставляйте всю страницу нести его.
Была ли у вас такая ошибка производительности, когда исправление оказалось крошечным? Особенно интересны случаи, когда профилировщик заставлял чувствовать себя лично задетым.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →