Чому функція push() робить інтерфейс React застарілим та як нові посилання це виправляють
Зрозумійте, чому зміна масивів у useState ніколи не спричиняє переробки компонента, як працює перевірка посилань у React, та коли варто використовувати копії з розповсюдженням елементів та функціональні оновлення.
Джерела: 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; це також робить код зрозумілішим для аналізу.
- Чіткі точки оновлення. Кожна зміна стану проходить через функцію-встановлювач, тож очевидно, які функції змінюють стан та де саме.
- Краще відловлення помилок. Оскільки кожне оновлення створює окреме значення, React DevTools та інші інструменти можуть показувати стан до та після кожної зміни.
- Відсутні приховані побічні ефекти. Оскільки дані ніколи не змінюються на місці, помилка в одному компоненті не може тихо пошкодити масив чи об’єкт, який інша частина додатку все ще читає.
Функціональні оновлення для швидких чи одночасних змін
Ще одна пов’язана проблема виникає під час частих оновлень, таких як швидкі повторні кліки або кілька асинхронних відповідей, що надходять майже одночасно, можливо, не в правильному порядку. Кожен обробник захоплює значення стану з того етапу відображення, у якому був створений. Якщо два оновлення відбуваються перед повторним відображенням, обидва можуть починатися з однакового застарілого знімка, і одна зміна перезаписує іншу. Це проблема застарілої замиканості, яка безпосередньо впливає на спосіб створення нового стану.
Рішення полягає у передачі функції до функції встановлення значень. 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, та скопіюйте кожен внутрішній рівень, який ви змінили.
Виправлення зазвичай потребує лише одного рядка, і саме воно визначає різницю між інтерфейсом, якому можна довіряти, та таким, що мовчки відображає застарілу інформацію.