Незмінність у стані React
Незмінність (immutability) у стані React означає, що при оновленні стану завжди створюються нові об'єкти чи масиви замість змін наявних, бо React порівнює посилання, а не значення всередині.
Теорія
TL;DR
- React використовує
Object.is()(порівняння посилань), щоб перевірити чи змінився стан. Не глибоке порівняння. - Якщо мутувати стан напряму, посилання залишається тим самим. React не бачить зміни і пропускає ре-рендер.
- Створюй нові об'єкти через spread:
{ ...obj, key: newValue }. Нові масиви черезmap,filterабо spread. - Правило вибору: якщо пишеш
state.щось = значення, це мутація. Так не спрацює.
Швидкий приклад
const [user, setUser] = useState({ name: "Alice", age: 25 });
// ❌ Неправильно - мутація на місці, те саме посилання, немає ре-рендеру
user.age = 26;
setUser(user);
// ✅ Правильно - новий об'єкт, нове посилання, React перерендерить
setUser({ ...user, age: 26 });
// Компонент ре-рендериться, відображає age: 26Spread-оператор створює новий об'єкт у пам'яті. React порівнює старе і нове посилання, бачить різницю і ставить ре-рендер у чергу.
Як React виявляє зміни стану
React зберігає посилання на стан у внутрішньому fiber tree. Кожен виклик setState запускає порівняння: Object.is(prevState, nextState). Якщо повертає true, React повністю пропускає функцію рендеру компонента. Якщо false, планує ре-рендер і оновлює DOM.
Саме тому setUser(user) після мутації user.age нічого не робить. Змінна user досі вказує на той самий об'єкт у пам'яті. React перевіряє, отримує true, іде далі.
Цей механізм також пояснює, чому React.memo, useMemo і useCallback працюють так, як вони працюють. Всі три будуються на тому самому порівнянні посилань.
Коли який підхід використовувати
- Оновити одне поле об'єкта:
setUser({ ...user, age: 26 }) - Оновити вкладений об'єкт: spread на кожному рівні,
setState({ ...state, user: { ...state.user, name: "Bob" } }) - Додати елемент до масиву:
setItems([...items, newItem]) - Видалити елемент з масиву:
setItems(items.filter(item => item.id !== id)) - Оновити один елемент у масиві:
setItems(items.map(item => item.id === id ? { ...item, ...changes } : item)) - Глибоко вкладений стан з багатьма рівнями: Immer
Довідник незмінних операцій
| Операція | Мутуюча | Незмінна |
|---|---|---|
| Додати до масиву | push(), unshift() | [...arr, item] |
| Видалити з масиву | splice(), pop() | filter() |
| Замінити в масиві | arr[i] = x | map() |
| Сортувати масив | sort() | [...arr].sort() |
| Оновити поле об'єкта | obj.key = val | { ...obj, key: val } |
| Видалити властивість | delete obj.key | const { key, ...rest } = obj |
Типові помилки
1. Мутація вкладеного об'єкта
// ❌ Неправильно - зовнішнє посилання не змінилось, ре-рендеру не буде
state.user.name = "Bob";
setState(state);
// ✅ Правильно - новий зовнішній об'єкт і новий вкладений
setState({ ...state, user: { ...state.user, name: "Bob" } });React перевіряє посилання зовнішнього об'єкта. Воно не змінилось. Ре-рендер пропущено.
2. Методи масиву, що мутують
// ❌ Неправильно - push() змінює оригінал, посилання те саме
items.push(newItem);
setItems(items);
// ✅ Правильно - нове посилання на масив
setItems([...items, newItem]);push(), pop(), splice(), sort() і reverse() змінюють оригінальний масив. Заміняй їх на немутуючі альтернативи.
3. Хибне уявлення що spread копіює вкладені об'єкти
// ❌ Частково неправильно - видаляє всі інші поля address
setUser({ ...user, address: { city: "NYC" } });
// ✅ Правильно - spread і на вкладеному рівні
setUser({ ...user, address: { ...user.address, city: "NYC" } });Spread поверхневий. Копіює поля першого рівня, але вкладені об'єкти досі є спільними посиланнями.
4. Мутація елемента всередині масиву після spread масиву
// ❌ Неправильно - масив новий, але todos[0] досі той самий об'єкт
const next = [...todos];
next[0].done = true;
setTodos(next);
// ✅ Правильно - новий масив, новий об'єкт елемента
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: true } : todo
));На практиці це найвідступніший баг мутації. Розробник, який вже знає "не мутуй масив напряму", робить spread, почувається впевнено, а потім годину дебажить чому елемент списку не оновився. Масив новий. Об'єкти всередині - ні.
5. Читання стану одразу після setState
// ❌ Хибне очікування - count досі старе значення
setCount(count + 1);
console.log(count);
// ✅ Функція-оновлювач, коли новий стан залежить від попереднього
setCount(prev => prev + 1);setState лише планує оновлення на наступний рендер. Змінна не оновлюється синхронно.
Де зустрічається
useState- кожне оновлення потребує нового посилання для ре-рендеру- Reducers у Redux - повинні повертати новий стан, ніколи не мутувати аргумент
- Zustand - функції оновлення стану мають повертати нові об'єкти
- React Query - оновлення кешу дотримуються тих самих правил
- Immer - дає змогу писати код у стилі мутації, який виробляє незмінні результати
React.memoіuseMemo- стабільні посилання запобігають зайвим ре-рендерам дочірніх компонентів
Питання на співбесіді
Q: Чому React використовує порівняння посилань, а не глибоке порівняння?
A: Глибоке порівняння перевіряє кожну вкладену властивість рекурсивно - це O(n) на кожне оновлення стану. Порівняння посилань - O(1). Крім того, це робить намір явним: створення нового об'єкта сигналізує "це змінене значення."
Q: Що станеться, якщо я мутував стан, але компонент все одно ре-рендерується через зміну пропсів батька?
A: Компонент ре-рендериться, але зі зламаним станом. Твоя мутація змінила старий об'єкт. Внутрішній стан React не оновлювався. Рендер використовує те, що є у fiber, ігноруючи мутацію. Отримуєш розбіжність у UI і важкий для відладки баг.
Q: Як Immer вирішує проблему глибоко вкладених оновлень без нескінченних spread-операторів?
A: Immer загортає стан у Proxy, який називається "draft" (чернетка). Ти пишеш пряму мутацію (draft.user.address.city = "NYC"), а Immer відстежує кожну зміну і виробляє новий незмінний об'єкт. Копіює лише ті гілки, які справді змінились, тому ефективний навіть для великих дерев стану.
Q: У мене 10 000 елементів у масиві. Чи не марнотратно створювати новий масив при кожному оновленні?
A: Spread великих масивів швидкий у сучасних JS-рушіях, тому здебільшого це не проблема. Але якщо часто оновлюєш окремі елементи, нормалізуй стан: зберігай елементи як об'єкт з ключами-ID замість масиву. Тоді оновлення одного елемента - це одне присвоєння властивості, а не копіювання всього масиву.
Приклади
Оновлення форми з вкладеними налаштуваннями
const [formData, setFormData] = useState({
email: "",
preferences: { notifications: true, theme: "dark" }
});
function handleEmailChange(e) {
// Змінюється тільки email; посилання на preferences залишається тим самим
setFormData({
...formData,
email: e.target.value
});
}
function toggleNotifications() {
// Новий зовнішній об'єкт і новий об'єкт preferences
setFormData({
...formData,
preferences: {
...formData.preferences,
notifications: !formData.preferences.notifications
}
});
}handleEmailChange створює новий об'єкт верхнього рівня. Властивість preferences досі вказує на той самий об'єкт - це нормально, бо вона не змінювалась. toggleNotifications потребує нового об'єкта preferences, бо саме він змінився.
Оновлення одного елемента в масиві об'єктів
const [todos, setTodos] = useState([
{ id: 1, title: "Вивчити React", tags: ["js", "react"] },
{ id: 2, title: "Зробити проєкт", tags: ["project"] }
]);
function addTagToTodo(todoId, newTag) {
setTodos(todos.map(todo =>
todo.id === todoId
? { ...todo, tags: [...todo.tags, newTag] }
: todo
));
}map() повертає новий масив. Потрібний todo отримує новий об'єкт з новим масивом tags. Інші todos зберігають оригінальні посилання. Це важливо для React.memo на елементах списку - незмінені елементи не будуть ре-рендеритися.
Складні вкладені оновлення з Immer
import { produce } from "immer";
const [state, setState] = useState({
users: [
{ id: 1, name: "Alice", scores: [90, 85] }
]
});
function addScore(userId, score) {
setState(produce(draft => {
const user = draft.users.find(u => u.id === userId);
if (user) {
user.scores.push(score); // Immer відстежує це і виробляє новий об'єкт
}
}));
}Без Immer потрібно spread'ити стан, масив users, знайти користувача, spread'ити його і spread'ити масив scores. П'ять рівнів spread для однієї зміни. Immer робить все це внутрішньо. Намір зрозумілий.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.