React вирішує, чи перерендерити компонент, порівнюючи посилання: новий стан має бути новим об'єктом. Зміна старого об'єкта «на місці» не помітна для React - і ламає логіку порівняння в memo, useEffect, useMemo.
Об'єкти - копія з розгортанням:
const [user, setUser] = useState({ name: 'Оля', address: { city: 'Київ', street: '' } });
// неправильно: мутація
user.address.city = 'Львів';
setUser(user);
// правильно: нові об'єкти на кожному рівні шляху до зміни
setUser({
...user,
address: { ...user.address, city: 'Львів' },
});
Розгортання (...) поверхневе - копіювати треба кожен рівень, що змінюється.
Масиви - методи, що повертають новий масив:
| Дія | Замість мутації | Без мутації |
|---|---|---|
| додати | push, unshift |
[...items, item] |
| видалити | splice |
items.filter((i) => i.id !== id) |
| змінити | items[i] = x |
items.map((i) => (i.id === id ? { ...i, done: true } : i)) |
| вставити | splice |
[...items.slice(0, n), item, ...items.slice(n)] |
| сортувати | sort, reverse |
items.toSorted(...), items.toReversed() |
Пастка зі «скопійованим» масивом: [...items] копіює масив, але елементи-об'єкти лишаються ті самі. copy[0].done = true змінює об'єкт і в старому стані.
Глибоко вкладений стан робить такі оновлення громіздкими. Два виходи:
- Immer (
use-immer): пишете «мутацію», а бібліотека створює новий незмінний об'єкт:
updateUser((draft) => {
draft.address.city = 'Львів';
});
- сплощити стан - зберігати сутності за
idв окремих полях замість глибокої вкладеності.
Функціональне оновлення для залежності від попереднього стану: setItems((prev) => [...prev, item]).
Чому це важливо не лише для рендеру: незмінність дає «знімки» історії (скасування дій), дешеве порівняння для оптимізацій і передбачувані ефекти.