Главная / Статьи

Почему функция push() делает интерфейс React устаревшим и как новые ссылки это исправляют

Поймите, почему изменение массивов в useState никогда не вызывает перерисовку, как работает проверка ссылок в React, и когда следует использовать копии с расширением и функциональные обновления.

Издатель: REACTAPP.TOP6 мин чтения1280 слов

Источники: React Survival Guide, Part 2: Don’t Mutate! Why `.push()` is Killing Your UI

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

Ошибка: данные меняются, а экран — нет

Представьте небольшую корзину покупок, список задач или таблицу пользователей. Последнее действие — добавление элемента в массив, хранящийся в useState, и первая попытка выполнения выглядит как обычный JavaScript.

const [cart, setCart] = useState(['Coffee']);

const addItem = (newItem) => {
  // It's just JavaScript, right?
  cart.push(newItem);
  console.log("Added! Cart is now:", cart);
};

После нажатия на «Добавить в корзину» в логе отображается ['Coffee', 'Tea'], значит данные кажутся правильными. Однако в отображаемой корзине по-прежнему указан только «Coffee». Интерфейс теперь показывает пользователям информацию, которая уже не соответствует действительности, что хуже сбоя, поскольку никто этого не замечает, пока об этом не сообщит клиент. В таких ситуациях разработчики часто тратят по часу на повторную проверку циклов и логики отображения, которые на самом деле не были причиной проблемы.

Обратите внимание, что этот обработчик совсем не вызывает функцию setCart. React не получил никаких сигналов о произошедших действиях, поэтому у него не было причин что-либо отображать. Более тонкая версия этой ошибки включает вызов setCart(cart) после операции push(), но экран также не меняется. В следующем разделе объясняется почему.

React сравнивает ссылки, а не содержимое

Основная идея, которую необходимо усвоить, проста: React отслеживает идентичность, а не содержимое.

Методы вроде .push(), .pop(), .splice() и .unshift(), а также операции присваивания вроде user.name = 'Alice' являются мутациями. Они указывают JavaScript на необходимость изменения значения, хранящегося в определенном месте памяти. JavaScript выполняет это немедленно, поэтому console.log(cart) отображает новый элемент. Однако React не отслеживает эту память. Его модель обновления предполагает, что состояние считается неизменяемым.

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

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

Решение: скопировать, изменить копию, установить копию

Изменчивость на самом деле не такая уж сложная. Чтобы обновить массив или объект в состоянии, оставьте исходный без изменений, создайте новый с внесёнными изменениями и передайте его функции-установщику. В современном JavaScript оператор распространения (...) — самый быстрый способ сделать это.

Исправленная функция addItem создаёт новый массив, в который добавляются старые элементы перед новым.

const addItem = (newItem) => {
  // The right way (creating a new identity):
  setCart([...cart, newItem]); // Create a new array, add newItem at the end.
};

Теперь функция-установщик получает другой ссылочный объект, проверка с помощью Object.is терпит неудачу, и React перерисовывает интерфейс с добавленным элементом. Тот же принцип применяется и к другим операциям:

  • Удаление: cart.filter(item => item !== target) вместо splice().
  • Замена: cart.map(item => (item.id === id ? { ...item, qty: item.qty + 1 } : item)).
  • Обновление объекта: setUser({ ...user, name: 'Alice' }) вместо присваивания значений свойствам.
  • Сортировка или инверсия: сначала сделайте копию с помощью [...cart].sort(), или используйте методы, не изменяющие исходные данные, toSorted() и toReversed(), если они поддерживаются вашими целевыми браузерами.
  • Имейте в виду, что операция распространения копии применяется только на одном уровне. Если состояние содержит вложенные объекты, для каждого уровня, который вы изменяете, требуется отдельная копия. Чтобы узнать больше о мутациях ссылок вне контекста состояния React, включая вложенные структуры, прочитайте статью о том, как избежать скрытых ошибок состояния из-за мутаций ссылок в JavaScript.

    Что дают вам неизменяемые обновления

    Это не просто правило, навязанное React; оно позволяет создавать код, который легче понимать.

    1. Четкие точки обновления. Каждое изменение состояния проходит через функцию-установщик, поэтому сразу видно, какие функции изменяют состояние и где.
    2. Улучшенная отладка. Поскольку каждое обновление создает уникальное значение, React DevTools и другие инструменты могут показывать состояние до и после каждого изменения.
    3. Отсутствие скрытых побочных эффектов. Поскольку данные никогда не изменяются на месте, ошибка в одном компоненте не может тайно повредить массив или объект, который по-прежнему читается другой частью приложения.

    Функциональные обновления для быстрых или одновременных изменений

    Вторая связанная проблема возникает при высокочастотных обновлениях, таких как быстрые повторные клики или несколько асинхронных ответов, поступающих один за другим, возможно, в неправильном порядке. Каждый обработчик сохраняет значение состояния из того момента отрисовки, когда он был создан. Если два обновления произойдут до повторной отрисовки, оба могут начинаться с одного и того же устаревшего состояния, в результате чего одно изменение перезаписывает другое. Это проблема устаревших замыканий, которая напрямую влияет на способ формирования нового состояния.

    Решение заключается в передаче функции в метод установки значения. React вызывает её с самым свежим текущим состоянием, так что каждое обновление основывается на результатах предыдущего, а не на сохранённом значении. В приведённом ниже примере добавляется группа сгенерированных элементов. Сначала из аргумента считывается значение prevCart.length для их нумерации, затем возвращается совершенно новый массив, в который включаются предыдущие элементы и новые.

    // A very fast counter:
    const addManyItems = (count) => {
      // Good: Uses spread to be immutable
      // Better: Uses functional update for consistency
      setCart(prevCart => {
        // We are generating new array content:
        const newItems = Array.from({ length: count }, (_, i) => `Item ${prevCart.length + i + 1}`);
        // Spread operator for the final, clean, new identity:
        return [...prevCart, ...newItems];
      });
    };
    

    Здесь работают два фактора. Операция распространения всё равно создаёт новый ссылочный объект, что заставляет React перерисовать интерфейс. Функция-обновитель гарантирует, что используется самый свежий массив, что предотвращает потерю обновлений. Хорошей практикой является использование функциональной формы при вычислении следующего состояния на основе предыдущего.

    Итоги

    • Мутация изменяет данные, но не ссылочный объект, поэтому проверка Object.is в React приходит к выводу, что ничего не изменилось, и пропускает процесс рендеринга.
    • Если интерфейс отстаёт от данных, сначала проверьте наличие операций .push(), .unshift(), .splice() или присваивания state.property = value к состоянию.
    • Создайте новый массив или объект с помощью операции распространения, функций map или filter, и скопируйте каждый изменяемый вложенный уровень.
  • Используйте функциональный обновитель, когда новое состояние зависит от старого, особенно при быстрых или асинхронных обновлениях.
  • Исправление обычно требует всего одной строки кода, и именно оно определяет разницу между интерфейсом, которому пользователи могут доверять, и тем, который незаметно отображает устаревшую информацию.