React_Patterns_Optimization

React Patterns: оптимизация

Оптимизация React-приложения — уменьшение лишних вычислений, повторных рендеров и объёма загружаемого JavaScript. Оптимизировать следует после измерений: повторный вызов компонента не обязательно означает изменение DOM или заметное замедление.

Для анализа используют React DevTools Profiler и Performance в браузере. В режиме разработки StrictMode может намеренно вызывать компоненты и эффекты повторно — это не поведение production-сборки.


useMemo

useMemo сохраняет результат вычисления между рендерами, пока зависимости не изменились.

import { useMemo } from "react";

function ProductList({ products, query }) {
  const visibleProducts = useMemo(() => {
    const normalizedQuery = query.trim().toLowerCase();

    return products.filter((product) =>
      product.title.toLowerCase().includes(normalizedQuery)
    );
  }, [products, query]);

  return (
    <ul>
      {visibleProducts.map((product) => (
        <li key={product.id}>{product.title}</li>
      ))}
    </ul>
  );
}

Сигнатура:

const cachedValue = useMemo(calculateValue, dependencies);

useMemo полезен, когда:

const options = useMemo(
  () => ({ sortBy, direction }),
  [sortBy, direction]
);

return <MemoizedTable rows={rows} options={options} />;

Без useMemo объект options создаётся заново при каждом рендере. Одинаковые по содержимому объекты всё равно различаются по ссылке.

Не следует мемоизировать простые операции без причины:

// Избыточно
const fullName = useMemo(
  () => `${firstName} ${lastName}`,
  [firstName, lastName]
);

Проще:

const fullName = `${firstName} ${lastName}`;

Мемоизация тоже имеет стоимость: React хранит результат и сравнивает зависимости. useMemo — оптимизация, а не замена состоянию и не гарантия постоянного хранения значения.

Все реактивные значения из функции вычисления должны находиться в зависимостях:

const total = useMemo(() => {
  return items.reduce(
    (sum, item) => sum + item.price * item.quantity * discount,
    0
  );
}, [items, discount]);

useCallback

useCallback сохраняет ссылку на функцию, пока её зависимости не изменились.

import { useCallback, useState } from "react";

function Cart() {
  const [items, setItems] = useState([]);

  const removeItem = useCallback((itemId) => {
    setItems((currentItems) =>
      currentItems.filter((item) => item.id !== itemId)
    );
  }, []);

  return <CartList items={items} onRemove={removeItem} />;
}

Сигнатура:

const cachedFunction = useCallback(callback, dependencies);

По смыслу useCallback(fn, deps) близок к useMemo(() => fn, deps), но предназначен для функций.

Основной сценарий — передача callback в компонент, обёрнутый в memo:

import { memo, useCallback, useState } from "react";

const SearchButton = memo(function SearchButton({ onSearch }) {
  return <button onClick={onSearch}>Найти</button>;
});

function SearchPage() {
  const [query, setQuery] = useState("");
  const [theme, setTheme] = useState("light");

  const handleSearch = useCallback(() => {
    console.log("Поиск:", query);
  }, [query]);

  return (
    <div className={theme}>
      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />
      <SearchButton onSearch={handleSearch} />
      <button onClick={() => setTheme((value) =>
        value === "light" ? "dark" : "light"
      )}>
        Сменить тему
      </button>
    </div>
  );
}

При смене темы ссылка handleSearch не меняется. При изменении query функция создаётся заново, чтобы использовать актуальный запрос.

Функциональное обновление состояния иногда позволяет убрать зависимость:

const addTodo = useCallback((title) => {
  setTodos((currentTodos) => [
    ...currentTodos,
    { id: crypto.randomUUID(), title, completed: false },
  ]);
}, []);

Для локальной функции, переданной обычному DOM-элементу, useCallback чаще всего не нужен:

<button onClick={() => setCount((value) => value + 1)}>
  {count}
</button>

React.memo

memo создаёт версию компонента, которая может пропустить повторный рендер, если пропсы не изменились.

import { memo } from "react";

const UserCard = memo(function UserCard({ name, email }) {
  return (
    <article>
      <h2>{name}</h2>
      <a href={`mailto:${email}`}>{email}</a>
    </article>
  );
});

React сравнивает каждый проп с предыдущим через Object.is(). Новый объект, массив или функция считаются новым значением:

// Новый объект при каждом рендере
<Results options={{ page, pageSize: 20 }} />

Стабильный вариант:

const options = useMemo(
  () => ({ page, pageSize: 20 }),
  [page]
);

return <Results options={options} />;

memo не предотвращает обновление, если изменились:

Можно передать собственную функцию сравнения:

const Chart = memo(ChartView, (previous, next) => {
  return previous.points === next.points;
});

Она возвращает true, если рендер можно пропустить. Глубокое сравнение больших структур может оказаться дороже рендера, поэтому собственное сравнение применяют только после измерений.

Инструмент Что сохраняет Типичный сценарий
useMemo результат вычисления тяжёлая операция, стабильный объект или массив
useCallback ссылку на функцию callback для мемоизированного компонента
memo возможность пропустить рендер дорогой компонент с неизменными пропсами

Кастомные хуки

Кастомный хук — функция с именем, начинающимся с use, которая объединяет переиспользуемую логику на основе других хуков.

Кастомный хук переиспользует логику, но не разделяет состояние: каждый вызов получает независимое состояние.

useDebouncedValue

import { useEffect, useState } from "react";

export function useDebouncedValue(value, delay = 300) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timeoutId = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timeoutId);
  }, [value, delay]);

  return debouncedValue;
}

Использование:

function SearchForm() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebouncedValue(query, 400);

  useEffect(() => {
    if (!debouncedQuery.trim()) return;
    console.log("Запрос:", debouncedQuery);
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
  );
}

Правила кастомных хуков

Хук не обязан хранить состояние:

function useDocumentTitle(title) {
  useEffect(() => {
    const previousTitle = document.title;
    document.title = title;

    return () => {
      document.title = previousTitle;
    };
  }, [title]);
}

Compound Components

Compound Components — набор связанных компонентов, которые образуют общий интерфейс и разделяют внутреннее состояние.

Публичный API может выглядеть как HTML-структура:

<Tabs defaultValue="description">
  <Tabs.List>
    <Tabs.Trigger value="description">Описание</Tabs.Trigger>
    <Tabs.Trigger value="delivery">Доставка</Tabs.Trigger>
  </Tabs.List>

  <Tabs.Panel value="description">Описание товара</Tabs.Panel>
  <Tabs.Panel value="delivery">Условия доставки</Tabs.Panel>
</Tabs>

Упрощённая реализация через Context API:

import { createContext, useContext, useMemo, useState } from "react";

const TabsContext = createContext(null);

function useTabs() {
  const context = useContext(TabsContext);

  if (!context) {
    throw new Error("Tabs-компоненты должны находиться внутри <Tabs>");
  }

  return context;
}

function Tabs({ defaultValue, children }) {
  const [activeValue, setActiveValue] = useState(defaultValue);
  const value = useMemo(
    () => ({ activeValue, setActiveValue }),
    [activeValue]
  );

  return (
    <TabsContext.Provider value={value}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function TabsList({ children }) {
  return <div role="tablist">{children}</div>;
}

function TabsTrigger({ value, children }) {
  const { activeValue, setActiveValue } = useTabs();

  return (
    <button
      role="tab"
      aria-selected={activeValue === value}
      onClick={() => setActiveValue(value)}
    >
      {children}
    </button>
  );
}

function TabsPanel({ value, children }) {
  const { activeValue } = useTabs();
  if (activeValue !== value) return null;
  return <div role="tabpanel">{children}</div>;
}

Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Panel = TabsPanel;

Преимущества:

Недостатки: больше компонентов, возможны широкие обновления Context, требуется внимательно реализовать доступность.


Render Props

Render props — паттерн, при котором компонент получает функцию и вызывает её для построения интерфейса.

function Toggle({ children }) {
  const [enabled, setEnabled] = useState(false);

  return children({
    enabled,
    toggle: () => setEnabled((value) => !value),
  });
}

Использование:

<Toggle>
  {({ enabled, toggle }) => (
    <section>
      <p>{enabled ? "Включено" : "Выключено"}</p>
      <button onClick={toggle}>Переключить</button>
    </section>
  )}
</Toggle>

Render props отделяет поведение от представления и позволяет потребителю полностью управлять разметкой. Недостаток — вложенные функции могут ухудшить читаемость. Многие подобные задачи сегодня удобнее решаются кастомными хуками, но render props остаётся полезным для гибких библиотечных компонентов.


Оптимизация повторных рендеров

Сначала измеряйте

React DevTools Profiler показывает, какие компоненты рендерились, сколько времени это заняло и какие обновления наиболее дорогие. Не каждый повторный рендер требует исправления.

Держите состояние ближе к месту использования

Если состояние нужно только поисковой панели, не обязательно размещать его в корне приложения:

function SearchPanel() {
  const [query, setQuery] = useState("");

  return (
    <input
      value={query}
      onChange={(event) => setQuery(event.target.value)}
    />
  );
}

Локальное состояние ограничивает область обновления.

Не храните производные данные в состоянии

Избыточно:

const [fullName, setFullName] = useState("");

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

Правильнее вычислить значение во время рендера:

const fullName = `${firstName} ${lastName}`;

Так нет дополнительного рендера и риска рассинхронизации.

Обновляйте данные иммутабельно

setUser((currentUser) => ({
  ...currentUser,
  name: "Ирина",
}));

Мутация текущего объекта мешает сравнению ссылок:

// Нежелательно
user.name = "Ирина";
setUser(user);

Разделяйте контексты

Один большой Context заставляет всех потребителей реагировать на изменение его значения. Независимые данные лучше разделять:

const ThemeContext = createContext(null);
const AuthContext = createContext(null);

Объект провайдера при необходимости стабилизируют:

const authValue = useMemo(
  () => ({ user, signIn, signOut }),
  [user, signIn, signOut]
);

Мемоизация не остановит обновление, если одно из значений действительно изменилось.

Используйте стабильные ключи

{users.map((user) => (
  <UserRow key={user.id} user={user} />
))}

Индекс массива нежелателен для списков, которые сортируются, фильтруются или изменяются. Нестабильный ключ может вызвать пересоздание компонентов и потерю локального состояния.

Используйте виртуализацию длинных списков

Если список содержит тысячи элементов, отображение только видимой части часто эффективнее мемоизации каждой строки. В DOM остаются, например, 20–50 видимых элементов вместо нескольких тысяч.

Не объявляйте компоненты внутри компонентов

// Нежелательно
function Page() {
  function Header() {
    return <h1>Каталог</h1>;
  }

  return <Header />;
}

При каждом рендере создаётся новый тип компонента. Объявите Header на уровне модуля.

Не выполняйте тяжёлую работу без необходимости

useMemo не переносит вычисление в другой поток. При изменении зависимостей функция всё равно блокирует главный поток. Для очень тяжёлых операций рассматривают Web Worker, серверную обработку или более эффективный алгоритм.


Code splitting и lazy loading

Code splitting разделяет приложение на chunks, загружаемые по требованию. Это уменьшает начальный объём JavaScript.

Динамический импорт:

const module = await import("./analytics.js");
module.trackPageView();

lazy() и Suspense

import { lazy, Suspense } from "react";

const AdminPage = lazy(() => import("./pages/AdminPage.jsx"));

function App() {
  return (
    <Suspense fallback={<p>Загрузка раздела...</p>}>
      <AdminPage />
    </Suspense>
  );
}

Загружаемый модуль обычно предоставляет default export:

export default function AdminPage() {
  return <h1>Панель управления</h1>;
}

Для именованного экспорта:

const SettingsPage = lazy(() =>
  import("./SettingsPage.jsx").then((module) => ({
    default: module.SettingsPage,
  }))
);

Разделение по маршрутам

const HomePage = lazy(() => import("./pages/HomePage.jsx"));
const CatalogPage = lazy(() => import("./pages/CatalogPage.jsx"));
const ProfilePage = lazy(() => import("./pages/ProfilePage.jsx"));

function AppRoutes() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/" element={<HomePage />} />
        <Route path="/catalog" element={<CatalogPage />} />
        <Route path="/profile" element={<ProfilePage />} />
      </Routes>
    </Suspense>
  );
}

Часто отдельно загружают страницы, административные разделы, редакторы, карты, графики и редко открываемые модальные окна.

Границы загрузки и ошибок

Локальные Suspense-границы позволяют не скрывать всю страницу при загрузке одной части. Ошибку динамического импорта должен перехватывать Error Boundary:

<ErrorBoundary fallback={<p>Не удалось загрузить раздел.</p>}>
  <Suspense fallback={<p>Загрузка...</p>}>
    <ReportsPage />
  </Suspense>
</ErrorBoundary>

Suspense показывает ожидание, Error Boundary — ошибку. Не следует создавать чрезмерное количество мелких chunks: запросы и управление загрузкой тоже имеют стоимость.


Общий пример

import {
  lazy,
  memo,
  Suspense,
  useCallback,
  useMemo,
  useState,
} from "react";

const ProductDetails = lazy(() => import("./ProductDetails.jsx"));

const ProductList = memo(function ProductList({ products, onSelect }) {
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          <button onClick={() => onSelect(product.id)}>
            {product.title}
          </button>
        </li>
      ))}
    </ul>
  );
});

function Catalog({ products }) {
  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState(null);
  const debouncedQuery = useDebouncedValue(query, 300);

  const visibleProducts = useMemo(() => {
    const value = debouncedQuery.trim().toLowerCase();
    if (!value) return products;

    return products.filter((product) =>
      product.title.toLowerCase().includes(value)
    );
  }, [products, debouncedQuery]);

  const handleSelect = useCallback((productId) => {
    setSelectedId(productId);
  }, []);

  return (
    <main>
      <h1>Каталог</h1>

      <input
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        placeholder="Поиск"
      />

      <ProductList
        products={visibleProducts}
        onSelect={handleSelect}
      />

      {selectedId !== null && (
        <Suspense fallback={<p>Загрузка карточки...</p>}>
          <ProductDetails productId={selectedId} />
        </Suspense>
      )}
    </main>
  );
}

Здесь кастомный хук задерживает применение поиска, useMemo сохраняет результат фильтрации, useCallback стабилизирует обработчик, memo позволяет списку пропускать часть рендеров, а lazy загружает карточку только после выбора товара.


Частые ошибки

Мемоизация всего подряд

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

Неполные зависимости

// discount пропущен — результат может устареть
const total = useMemo(
  () => calculateTotal(items, discount),
  [items]
);

Правильно: [items, discount].

Мутация данных при сортировке

// sort изменяет исходный массив
const sorted = useMemo(
  () => products.sort(compareProducts),
  [products]
);

Безопаснее:

const sorted = useMemo(
  () => [...products].sort(compareProducts),
  [products]
);

Либо используйте toSorted(), если он поддерживается целевой средой.

Ожидание, что memo остановит Context

Компонент с useContext обновляется при изменении значения контекста даже при обёртке в memo. Уменьшайте область контекста или разделяйте контексты.

Слишком крупная Suspense-граница

Если граница окружает всю страницу, загрузка небольшого блока может скрыть уже доступный интерфейс. Располагайте границы по пользовательским сценариям.


Рекомендации

  1. Сначала пишите понятный и корректный код.
  2. Перед оптимизацией измеряйте производительность.
  3. Размещайте состояние ближе к месту использования.
  4. Не храните производные значения в состоянии.
  5. Используйте memo для дорогих компонентов с часто неизменными пропсами.
  6. Применяйте useMemo для заметных вычислений или стабильных ссылок.
  7. Применяйте useCallback, когда стабильная функция действительно нужна.
  8. Обновляйте данные иммутабельно и используйте стабильные ключи.
  9. Разделяйте крупные контексты.
  10. Используйте code splitting для маршрутов и тяжёлых редких функций.
  11. Для длинных списков рассматривайте виртуализацию.
  12. После изменения снова измеряйте результат.

Главный принцип: не нужно предотвращать все рендеры — нужно уменьшать стоимость тех обновлений, которые действительно замедляют интерфейс.