open atlas
↑ К треку
Паттерны React RXP · 07 · 02

Селекторы и tearing

Селектор подписывает компонент только на читаемый срез стора, и посторонние изменения пропускают его рендер; читай через хук стора, чтобы избежать tearing, и давай поверхностное равенство селектору, строящему свежий объект, — иначе он рендерится на каждое изменение.

RXP Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Внешний стор держит всё: корзину, пользователя, дюжину фича-флагов, состояние открыт/закрыт у сайдбара. Переключи сайдбар — и если каждый компонент просто читает «стор», перерисовывается каждый компонент. Стор, растущий таким образом, не масштабируется: он превращает одну крошечную мутацию в рендер-шторм на всё дерево, и чем больше приложение, тем хуже.

Лекарство — селектор: функция, что вытаскивает только нужный компоненту срез, так что компонент перерисовывается, когда меняется этот срез, и игнорирует всё остальное. Сделай селектор правильно — и стор остаётся быстрым на любом размере. Ошибись в его равенстве — возвращай свежий объект на каждый вызов — и ты построишь компонент, который перерисовывается на каждое изменение стора, тот самый шторм, который ты пытался остановить.

Цель

После этого урока ты можешь написать селектор, подписывающий компонент на единственный срез стора; объяснить, почему ты читаешь через официальный хук стора, а не через сырую модульную переменную (tearing при конкурентном рендеринге); распознать ловушку равенства «новый объект на каждый рендер», которая молча сводит на нет мемоизацию селектора; и исправить её компаратором поверхностного равенства — зная при этом, когда селектор нормально оставить без мемоизации.

1

Селектор — это и есть подписка. Читай срез — и перерисовывайся только когда меняется этот срез. Стор предоставляет хук, который принимает функцию-селектор; хук запускает селектор, запоминает результат и перезапускает твой компонент только когда выбранное значение меняется по проверке равенства стора. Чтение всего объекта стора означает, что любое изменение где угодно тебя будит.

// store.ts (в стиле Zustand; форма одинакова для любого стора с селекторами)
const useCart = create<CartState>((set) => ({
  items: [],
  couponOpen: false,
  addItem: (i) => set((s) => ({ items: [...s.items, i] })),
}));

// читает ОДНО поле → перерисовывается только когда меняется items.length
function CartBadge() {
  const count = useCart((s) => s.items.length);
  return <span>Cart ({count})</span>;
}

CartBadge теперь слеп к couponOpen, к правкам купона, ко всему кроме количества товаров. Эта избирательность и есть паттерн — без неё бейдж перерисовывался бы каждый раз, когда кто-нибудь трогает любое поле.

2

Читай через хук стора, никогда через сырую модульную переменную — именно это защищает тебя от tearing. «Tearing» (разрыв) — это когда один проход рендера читает два разных значения одного и того же состояния, потому что значение изменилось посреди рендера. React 18+ рендерит конкурентно: он может приостановить, уступить и возобновить рендер. Если компонент читает обычную мутабельную модульную переменную напрямую, обновление между «уступить» и «возобновить» порождает рендер, где часть дерева видела старое значение, а часть — новое: разорванный, внутренне несогласованный UI.

// ❌ сырое чтение — нет подписки, и tearing при конкурентном рендеринге
let cartModule = { items: [] as Item[] };
function Bad() {
  return <span>{cartModule.items.length}</span>; // устаревшее + риск tearing
}

// ✅ хук стора построен на useSyncExternalStore, который гарантирует,
// что каждое чтение в проходе рендера видит один консистентный снимок
function Good() {
  const count = useCart((s) => s.items.length);
  return <span>{count}</span>;
}

Хук стора построен на useSyncExternalStore, чья единственная задача — давать React один консистентный снимок на рендер и форсировать синхронную перерисовку, если внешнее значение изменилось во время конкурентного прохода. Вот почему правило «используй хук, а не переменную» существует — это не стиль, это корректность.

3

Ловушка: селектор, строящий свежий объект или массив на каждый вызов, ломает мемоизацию и перерисовывается бесконечно. Стор по умолчанию решает «изменилось ли выбранное значение?» сравнением по ссылке (Object.is). Селектор, возвращающий { a, b } или [x, y], конструирует совершенно новую ссылку на каждый запуск, так что сравнение всегда «изменилось», так что компонент перерисовывается на каждое обновление стора — тот самый режим отказа, ради предотвращения которого существует весь этот урок.

// ❌ свежий объект на каждый вызов → Object.is(prev, next) всегда false
// → перерисовка на КАЖДОЕ изменение стора, даже не относящееся
function CartSummary() {
  const { count, total } = useCart((s) => ({
    count: s.items.length,
    total: s.items.reduce((n, i) => n + i.price, 0),
  }));
  return <p>{count} items · ${total}</p>;
}

Выглядит корректно и рендерит правильные числа — поэтому и переживает ревью. Баг безмолвен: это не неверный вывод, это потраченные впустую рендеры, невидимые, пока профайлер не покажет этот компонент, мигающий на каждое нажатие клавиши где-то ещё в приложении.

4

Исправление: скажи стору сравнивать выбранное значение по поверхностному равенству — либо выбирай примитивы. Поверхностное равенство сравнивает каждый ключ/элемент верхнего уровня по ссылке вместо сравнения ссылки контейнера. Теперь { count: 2, total: 40 } равен { count: 2, total: 40 } прошлого рендера, так что перерисовка не срабатывает, пока поле реально не изменится.

import { useShallow } from "zustand/react/shallow";

// ✅ поверхностное равенство на возвращаемом объекте → перерисовка только
// когда count или total реально меняются
function CartSummary() {
  const { count, total } = useCart(
    useShallow((s) => ({
      count: s.items.length,
      total: s.items.reduce((n, i) => n + i.price, 0),
    })),
  );
  return <p>{count} items · ${total}</p>;
}

// ✅ или обойди это вовсе: выбери два примитива в двух хуках
function CartSummaryPrimitive() {
  const count = useCart((s) => s.items.length);
  const total = useCart((s) => s.items.reduce((n, i) => n + i.price, 0));
  return <p>{count} items · ${total}</p>;
}

Правило большого пальца: селектору, возвращающему примитив (число, строку, булево), функция равенства не нужна — Object.is уже делает правильное. Селектор, возвращающий свежий объект или массив, обязан идти в паре с компаратором поверхностного равенства, иначе он сводит на нет ту самую мемоизацию, ради которой селектор и существует.

Разбор примера

Один компонент, исправленный изменением только селектора. Шапка показывает имя и аватар вошедшего пользователя из стора, который также держит быстро меняющееся поле — скажем, cursorPosition, обновляемое на каждое движение мыши для фичи совместной работы.

До — выбор свежего объекта без функции равенства:

function UserChip() {
  // свежий объект на каждый рендер; cursorPosition срабатывает постоянно,
  // и поскольку Object.is всегда false, это перерисовывается на каждое
  // движение мыши, хотя name/avatar никогда не меняются
  const { name, avatar } = useApp((s) => ({
    name: s.user.name,
    avatar: s.user.avatarUrl,
  }));
  return (
    <span>
      <img src={avatar} alt="" /> {name}
    </span>
  );
}

В профайлере UserChip мигает на каждое обновление курсора — десятки рендеров в секунду для компонента, чьи данные статичны всю сессию. Вывод правильный; цена скрыта.

После — та же форма плюс поверхностное равенство:

import { useShallow } from "zustand/react/shallow";

function UserChip() {
  const { name, avatar } = useApp(
    useShallow((s) => ({ name: s.user.name, avatar: s.user.avatarUrl })),
  );
  return (
    <span>
      <img src={avatar} alt="" /> {name}
    </span>
  );
}

Теперь поверхностное равенство сравнивает name и avatar по ссылке; оба неизменны на движении курсора, так что UserChip пропускает рендер целиком. Он перерисовывается только когда имя или аватар реально меняются. Разница — один импорт и одна обёртка, но она превращает компонент, рендерившийся сотни раз в минуту, в тот, что рендерится только когда меняются его данные. Это и есть разница между стором, который масштабируется, и тем, что топит приложение в рендерах.

Почему это работает

Почему стор сравнивает выбранное значение, а не весь стор? Потому что селектор — это граница. Стор не может знать, какие поля тебе важны, — только твой селектор выражает это. Поэтому он мемоизирует по возвращаемому значению селектора: если это значение «то же самое» по функции равенства, твоему компоненту говорят, что ничего не изменилось. Именно поэтому функция равенства так важна. Селектор говорит, что ты читаешь; функция равенства говорит, что считается изменением для него. Ошибись в любом — и подписка неверна: слишком широкий селектор подписывает тебя на шум; слишком слабое равенство (свежая ссылка) сообщает об изменении на каждый тик.

Частая ошибка

Не хватайся рефлекторно за useMemo, чтобы починить проблему свежего объекта, — он её не чинит. useMemo мемоизирует значение внутри одного рендера одного компонента, но селектор работает внутри подписки стора, сравнивая между рендерами. Стору нужна функция равенства (useShallow или кастомный компаратор), а не мемоизированный селектор. Противоположная гиперкоррекция столь же часта: оборачивать каждый примитивный селектор в useShallow. Селектор, возвращающий одно число или строку, уже корректно сравнивается через Object.is — добавлять туда поверхностное равенство чистый шум. Тянись к поверхностному равенству ровно тогда, и только тогда, когда селектор возвращает свежий объект или массив.

Проверь себя
Викторина

Коллега пишет useStore((s) => ({ id: s.user.id, plan: s.user.plan })), и компонент перерисовывается на каждое не относящееся обновление стора, хотя id и plan никогда не меняются. В чём причина и какое правильное исправление?

Итог

Селектор — это то, как компонент подписывается на стор: он читает единственный срез, так что компонент перерисовывается только когда меняется этот срез, и игнорирует каждую не относящуюся мутацию — что и есть вся причина, по которой сторы остаются быстрыми по мере роста. Всегда читай через хук стора (построенный на useSyncExternalStore), никогда через сырую модульную переменную, потому что хук гарантирует один консистентный снимок на рендер и предотвращает tearing при конкурентном рендеринге. Сигнатурный режим отказа: селектор, строящий свежий объект или массив на каждый вызов, побеждает проверку стора на ссылочное равенство, так что компонент перерисовывается на каждое обновление стора — безмолвно, ведь вывод всё ещё правильный. Исправь это выбором примитивов (которые корректно сравниваются под Object.is) либо передачей функции поверхностного равенства (useShallow), когда обязан вернуть составное значение. Дисциплина мала и точна: примитивным селекторам компаратор не нужен; составным — обязателен, и useMemo им не является.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.