Ссылочная семантика, поверхностное копирование, неизменяемые операции с массивами и безопасное обновление вложенных данных.
Разберём поведение JavaScript, из-за которого изменения состояния в React чаще всего дают неожиданный результат.
Возьмём список товаров и добавим выбор одного элемента. По ходу работы выясним, почему копии через spread иногда недостаточно, какие методы массива меняют исходные данные, как замыкание запоминает переменные и зачем React нужны новые ссылки на изменившиеся объекты.
Весь код остаётся на TypeScript, но в этом уроке нас интересует именно поведение JavaScript во время выполнения.
Число или строка копируются как самостоятельные значения. С переменными, которые указывают на объект, всё иначе: две переменные могут вести к одному объекту.
const product = { id: 'p1', selected: false };
const sameProduct = product;
sameProduct.selected = true;
console.log(product.selected); // trueНового товара здесь не появилось. Мы изменили единственный объект через вторую ссылку на него.
React сравнивает старое и новое состояние по ссылкам. Если изменить старый объект на месте, предыдущий снимок состояния тоже окажется испорчен, а React не получит надёжного признака изменения.
Проверьте себя. Добавьте console.log(product === sameProduct). Результат true подтверждает, что обе переменные указывают на один объект.
Частая ошибка. Считать const защитой от изменений: он запрещает переприсваивание переменной, но не изменение полей объекта.
У товара появилась вложенная настройка:
type Item = {
id: string;
meta: { selected: boolean };
};
const items: Item[] = [
{ id: 'p1', meta: { selected: false } },
];
const copy = [...items];
copy[0].meta.selected = true;Массив copy новый, но copy[0] и items[0] всё ещё указывают на один товар. Вложенный meta также остался общим. Поэтому items[0].meta.selected неожиданно стал true.
Правило простое: копируйте каждый уровень, который собираетесь изменить.
function select(items: readonly Item[], id: string): Item[] {
return items.map((item) =>
item.id === id
? { ...item, meta: { ...item.meta, selected: true } }
: item,
);
}Неизменившиеся товары можно оставить прежними. Новый объект нужен только выбранному товару и его изменившемуся meta.
Проверьте себя. Убедитесь тестом, что новый массив отличается от исходного, выбранный товар получил новую ссылку, а остальные товары сохранили прежние ссылки.
Частая ошибка. Скопировать только внешний объект и изменить вложенное поле — старый снимок изменится вместе с новым.
map, filter, slice и toSorted возвращают новый массив. push, splice, sort и reverse меняют существующий.
const visible = items.filter((item) => item.meta.selected);
const ordered = items.toSorted((a, b) => a.id.localeCompare(b.id));visible и ordered можно вычислить из исходных данных во время отрисовки. Не стоит записывать их в отдельное состояние: иначе придётся вручную синхронизировать несколько копий одной информации.
Если старый браузер не поддерживает toSorted, используйте [...items].sort(...): сначала создайте новый массив и только затем сортируйте его.
Таблица помогает не запоминать наугад:
| Меняют исходный массив | Возвращают новый |
|---|---|
push, pop, shift, unshift | concat, slice, map, filter |
splice, sort, reverse, fill | toSpliced, toSorted, toReversed, with |
Проверьте себя. Отсортируйте список товаров, не меняя исходный массив, и убедитесь, что прежний порядок сохранился.
Частая ошибка. Вызвать items.sort(...) для массива из состояния. Порядок изменится и в старом снимке.
Функция, созданная внутри другой функции, продолжает видеть её переменные. Это называется замыканием, и именно оно объясняет «устаревшие» значения в асинхронном коде React.
function createLogger(prefix: string) {
let count = 0;
return function log(message: string) {
count += 1; // видит переменную, а не её копию
console.log(`${prefix} #${count}: ${message}`);
};
}Каждый вызов createLogger создаёт свой набор переменных, поэтому два логгера считают независимо. Обратная сторона того же механизма — функция может запомнить значение, которое к моменту её вызова уже неактуально:
let selectedId = 'p1';
const show = () => console.log(selectedId); // прочитает значение в момент вызова
selectedId = 'p2';
show(); // p2 — не 'p1'В React каждый вызов компонента создаёт новые локальные переменные для этого вызова. Обработчик, созданный в одном вызове, навсегда видит переменные именно того вызова, — отсюда правило «состояние в обработчике является снимком».
Проверьте себя. Создайте два логгера и убедитесь, что их счётчики независимы.
Частая ошибка. Ожидать, что функция прочитает значение переменной на момент своего создания, а не вызова.
===, Object.is и поверхностное сравнениеПримитивы сравниваются по значению, объекты — по ссылке. React использует Object.is, который почти совпадает с ===, но иначе обрабатывает два особых случая.
{ id: 'p1' } === { id: 'p1' }; // false: два разных объекта
Object.is(NaN, NaN); // true, а NaN === NaN даёт false
Object.is(0, -0); // false, а 0 === -0 даёт trueПоэтому «одинаковые по содержимому» объекты для React разные, и наоборот: изменённый на месте объект остаётся тем же самым.
const next = state;
next.items.push(item); // содержимое изменилось
Object.is(state, next); // true — React не увидит измененияОтсюда следует практическое правило: чтобы React заметил изменение, нужна новая ссылка на изменившийся объект. Именно это и делает копирование пути из раздела 2.
Проверьте себя. Сравните два объекта с одинаковыми полями через === и убедитесь, что результат false.
Частая ошибка. Изменить массив в состоянии методом push и ждать перерисовки.
?., ?? и undefinedДанные из API часто содержат null или не содержат поля вовсе. Опциональная цепочка и оператор ?? позволяют обработать это без длинных проверок.
const city = order.customer?.address?.city ?? 'не указан';Важно различать ?? и ||: первый подставляет значение только для null и undefined, второй — для любого ложного значения.
const quantity = input.quantity ?? 1; // 0 останется нулём
const wrong = input.quantity || 1; // 0 превратится в 1Для количества, цены и координат разница принципиальна: || молча превращает корректный нуль в значение по умолчанию. Отдельно помните, что ?. не защищает от неверного типа — только от отсутствия свойства.
Проверьте себя. Передайте quantity: 0 и сравните результат ?? и ||.
Частая ошибка. Использовать || для значений по умолчанию у чисел и строк.
Код верхнего уровня модуля выполняется один раз при загрузке. Изменяемая переменная такого модуля становится общей для всех компонентов, которые его импортировали.
let selectedId: string | null = null;
export function selectProduct(id: string) {
selectedId = id;
}Это не локальное состояние компонента. Две открытые карточки будут менять одну переменную, а React не узнает о её изменении и не запустит новую отрисовку.
В модуле безопасно хранить неизменяемые настройки и чистые функции. Пользовательское состояние должно принадлежать компоненту, внешнему хранилищу с подпиской или серверу — выбор зависит от области его жизни.
Проверьте себя. Импортируйте модуль в двух компонентах и убедитесь, что изменение переменной не вызывает отрисовку.
Частая ошибка. Хранить выбранный элемент или черновик формы в переменной модуля «чтобы не пробрасывать свойства».
Для функции select полезны четыре проверки:
selected: true;const source: Item[] = [
{ id: 'p1', meta: { selected: false } },
{ id: 'p2', meta: { selected: false } },
];
const result = select(source, 'p1');
expect(result).not.toBe(source);
expect(result[0]).not.toBe(source[0]);
expect(result[0].meta).not.toBe(source[0].meta);
expect(result[1]).toBe(source[1]);
expect(source[0].meta.selected).toBe(false);Здесь проверяется не способ написания функции, а важное для React поведение: старый снимок остаётся целым, изменённая ветвь получает новые ссылки, неизменённая не копируется.
Проверьте себя. Добавьте пятую проверку: повторный вызов select с тем же идентификатором возвращает новый массив, но не меняет исходный.
Частая ошибка. Проверять только итоговое значение поля, не убедившись, что исходные данные остались целыми.
Следующий урок применит эти правила к данным неизвестной формы, полученным из API.
Далее: TypeScript: данные из API без any