Объекты и массивы в состоянии

состояние≈ 11 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В части II задача попадала на доску двумя строками: state.tasks.push(task) и render(). В React второй строки нет — и первая внезапно перестаёт работать: в массиве задача есть, а список на экране прежний. React узнаёт об изменении по единственному признаку — новой ссылке, поэтому объекты и массивы в состоянии меняются копированием, а не на месте.

Разделы темы:·

Список задач лежит в состоянии массивом. Обработчик кнопки делает с ним то же, что делал код доски в части II, — push. Перед запуском ответьте: сколько пунктов окажется на экране и сколько раз напечатается «отрисовка».

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В массиве две задачи, на экране одна, и второй строки «отрисовка» нет. В части II после push вы звали render() сами, и разметка перестраивалась из того, что лежит в состоянии, — каким бы способом оно ни поменялось. В React такой команды нет: единственный способ попросить отрисовку — сеттер. Добавим его.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сеттер вызван, а отрисовка по-прежнему одна. React получил tasks, сравнил с тем, что уже лежит у него в памяти, — и это оказался тот же самый массив. Для него это значит «ничего не изменилось», и он экономит работу: не вызывает компонент и не трогает DOM. Что внутри массива стало на элемент больше, он не проверяет — и не будет.

Сеттер — не команда «перерисуй». Это заявка «вот новое значение», и React вправе решить, что новое совпадает со старым.

Правило одно и очень дешёвое: новое значение сравнивается со старым через Object.is. Для чисел и строк это сравнение по значению — setCount(5) при пятёрке в состоянии ничего не сделает, и это разумно. Для объектов и массивов Object.is сравнивает ссылки: одно ли это место в памяти. Про ссылки вы читали в части I; здесь они из ловушки превращаются в рабочий инструмент.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсюда обе стороны правила. Мутация оставляет ссылку прежней — React не заметит изменения. Копия даёт новую ссылку — React перерисует, даже если внутри всё то же самое. Содержимое он не сверяет ни в одну сторону: это было бы дорого на большом состоянии и невозможно на объектах с циклами.

Мутация при этом никуда не пропадает: массив в памяти React изменён, просто отрисовки не было. Она случится по любому другому поводу — и покажет всё, что накопилось. Ниже вторая кнопка меняет своё, отдельное состояние. Что будет в списке после неё?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Задача появилась на экране от кнопки, которая к задачам отношения не имеет. Это худший вид ошибки: ничего не падает и не всегда воспроизводится — экран отстаёт от состояния ровно до следующей чужой отрисовки. В части II разметка отставала, когда вы забывали вызвать render(); здесь она отстаёт, когда вы меняете состояние на месте.

Что передали в setЧто видит Object.isОтрисовка
setCount(count)то же числонет
setTasks(tasks) после pushта же ссылканет — и push остаётся невидимым
setTasks([...tasks, task])новая ссылкада
setUser({ ...user }) без измененийновая ссылкада, хотя показывать нечего

Раз сигнал для React — новая ссылка, изменение состояния складывается из двух шагов: собрать копию с нужными правками и отдать её сеттеру. Для массива это spread из части I.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Две отрисовки, два пункта. Старый массив никто не трогал: он остался в предыдущем снимке ровно таким, каким его видела предыдущая отрисовка. Снимок из статьи «Состояние — снимок, а не переменная» держится именно на этом: значение не меняют, его заменяют.

С объектом то же самое: { ...user, city: "Казань" } — копия, в которой переписано одно поле. Здесь у копирования есть приятный побочный эффект: поле пишется в литерале, а литерал компилятор проверяет на лишние свойства — вы видели это в части III. В примере ниже опечатка в имени поля. Что скажет компилятор и что напечатает пример?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Компилятор находит лишнее поле: «Объектный литерал может использовать только известные свойства. "cty" не существует в типе "SetStateAction<Profile>"». Назван не Profile, а SetStateAction<Profile> — аргумент сеттера, знакомый по прошлым статьям; сверка идёт с формой состояния. Пример всё равно выполняется, и вторая отрисовка честно печатает «Рико Москва»: ссылка новая, React перерисовал, а город не сменился — в состоянии появилось поле cty, которое никто не читает. В части II такая опечатка молча жила бы в объекте до первого недоумения пользователя. Исправьте cty на city и запустите ещё раз.

Если новое значение зависит от старого — а при копировании это всегда так, — годится и обновитель из статьи «Функция-обновитель и очередь обновлений»: setTasks((t) => [...t, task]). Он спасает, когда добавлений в одном событии несколько или они отложены.

НужноМеняет на месте — не годитсяВозвращает новый массив
добавитьpush, unshift[...arr, x], [x, ...arr]
удалитьpop, shift, splicefilter, slice, toSpliced
заменить элементarr[i] = xmap, with
упорядочитьsort, reversetoSorted, toReversed, [...arr].sort()

Правая колонка — целиком из части I, кроме четырёх новичков: toSorted, toReversed, toSpliced и with добавлены в ES2023 как раз для такого кода. Они делают то же, что старые собратья, но не трогают исходный массив, а возвращают новый.

Мутировать можно то, что создали только что и никому не отдали: const next = [...tasks]; next.push(x); setTasks(next) — честный код. Опасна мутация того, что уже лежит в состоянии.

Spread копирует один уровень: примитивы переписываются в копию, а вложенные объекты попадают туда той же ссылкой — вспомните раздел «Где заканчивается поверхностная копия» в статье про копирование объектов. Пока вы меняете верхний уровень, этого не видно. Как только меняется вложенное, старое и новое состояние начинают делить один объект.

Ниже доска с историей: перед каждым изменением текущий список откладывается в past, чтобы потом сделать «отменить». Первую задачу переключают на месте, а массив копируют — так что отрисовка будет. Вопрос в другом: что окажется в истории?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На экране всё верно, а в истории ложь: там лежит done = true, хотя откладывали снимок с false. Массив в past старый, но задача внутри него — та же самая, что и в новом массиве, и присваивание переписало её в обоих. «Отменить» теперь вернёт то, что и так на экране. Так же ломается всё, что держит ссылку на прошлый снимок: сравнение «было / стало», отложенный обработчик, дочерний компонент, который решил не перерисовываться.

Починка — копировать по всему пути от корня состояния до изменённого поля. Для элемента массива это map: новый объект только для нужной задачи, остальные отдаются как есть.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Для вложенного объекта путь тот же, только вместо map — spread на каждом уровне: setUser({ ...user, address: { ...user.address, city: "Казань" } }). Два уровня читаются нормально, три — уже с трудом; что делать глубже, чуть ниже.

Правило пути: новая ссылка нужна каждому объекту от корня состояния до изменённого поля. Всё в стороне от пути можно отдавать старыми ссылками — это и дёшево, и правильно.

Первый выход — structuredClone из части I: глубокая копия всего состояния, которую потом можно править как угодно и отдать сеттеру. Плата — копируется всё, включая ветки, которых вы не касались, и по ссылкам React уже не отличит изменённое от нетронутого. Для небольшого состояния это приемлемо.

Второй — библиотека Immer: produce(state, (draft) => { draft.address.city = "Казань"; }) выглядит как мутация, а возвращает новое состояние с копиями ровно по изменённому пути. В песочнице её не запустить — импорты недоступны, — но в проекте это одна зависимость.

Третий выход — не плодить вложенность: плоский список задач с id вместо дерева правится одним map. Как выбирать форму состояния, разбирает последняя статья главы.

Пока состояние описано типом Task[], компилятор считает push законным — для обычного массива он и есть законный. Но тип состояния выбираете вы. Объявите его как readonly Task[], и мутация станет ошибкой сборки, а не сюрпризом в интерфейсе. Прежде чем запускать, решите: какие из трёх обработчиков компилятор отвергнет?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отвергнут только push: «Свойство "push" не существует в типе "readonly Task[]"». У readonly-массива просто нет методов, меняющих его на месте, — зато остались все, что возвращают новый: map, filter, toSorted, spread. Обычный массив в readonly подставляется без слов, поэтому addGood прошёл.

А вот tasks[0].done = true прошло молча — и это то самое «обещание глубиной в один уровень» из части III. Readonly у массива запрещает трогать массив, а не его элементы: тип элемента по-прежнему Task с обычными полями. Чтобы замок стоял и на полях, их тоже помечают.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

«Не удается задать значение для "done", так как это свойство, доступное только для чтения» — и мутация вложенного объекта тоже поймана до запуска. Копирующий обработчик при этом компилируется: spread собирает новый объект, а литерал с readonly-полями создавать никто не запрещает — запрещено только менять их потом. Писать readonly перед каждым полем не обязательно: Readonly<Task> из части III делает то же одним словом.

readonly живёт только в типах: собранный JavaScript выполнит push как ни в чём не бывало, вы видели это в части III. Поэтому примеры с ошибкой всё равно запускаются.

Подстановка работает в одну сторону: обычный массив годится вместо readonly, обратное — нет. Функция с параметром Task[] readonly-состояние не примет, и компилятор скажет: «Аргумент типа "readonly Task[]" нельзя назначить параметру типа "Task[]"», а причиной назовёт: «Тип "readonly Task[]" является "readonly" и не может быть назначен изменяемому типу "Task[]"».

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Это подсказка не про состояние, а про функцию: раз она ничего не мутирует, её параметр должен быть readonly Task[] — тогда она примет и то и другое. Так readonly в состоянии постепенно наводит порядок в сигнатурах вокруг.

  • Сеттер сравнивает новое значение со старым через Object.is: для объектов и массивов это сравнение ссылок, содержимое React не сверяет.
  • Мутация — push, splice, присваивание в поле — оставляет ссылку прежней, и React не перерисовывает; изменение всплывёт при следующей чужой отрисовке.
  • Состояние меняют копированием: spread, map, filter и методы ES2023 toSorted, toReversed, toSpliced, with возвращают новый массив, не трогая старый.
  • Spread копирует один уровень; вложенный объект нужно копировать по всему пути от корня до изменённого поля, иначе прошлые снимки испортятся.
  • readonly Task[] и readonly-поля превращают мутацию в ошибку компилятора; копирующие методы и подстановка обычного массива при этом остаются.

1/4В обработчике две строки: tasks.push(task), затем setTasks(tasks). Что произойдёт после клика?

2/4Состояние user — объект. Обработчик вызывает setUser({ ...user }), не меняя ни одного поля. Будет ли отрисовка?

3/4Обработчик делает const next = { ...user }; next.address.city = "Казань"; setUser(next). Что здесь не так?

4/4Состояние объявлено как useState<readonly Task[]>([]). Что скажет компилятор на setTasks([...tasks, task])?

Галочка, которая не ставится

Клик по задаче должен переключать её done. Обработчик находит задачу, меняет поле, вызывает сеттер — а галочка не появляется. Почините, не меняя разметку.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Задача изменена на месте, а в сеттер ушёл тот же массив: Object.is даёт true, отрисовки нет. Нужны новая ссылка на массив и новый объект для изменённой задачи — это один map.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Остальные задачи map возвращает прежними ссылками — копировать их незачем. Проверка через 150 мс напечатает [x] Написать компонент.

Удаление без splice

Кнопка «убрать» должна удалять задачу с id === 1. Автор вспомнил splice из части I — и список не изменился. Перепишите обработчик так, чтобы состояние не мутировалось, и назовите второй способ, кроме того, что выбрали.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Самый прямой способ — filter: он возвращает новый массив без лишнего элемента и не требует искать индекс.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Второй способ — toSpliced из ES2023: та же подпись, что у splice, но исходный массив не трогается, а возвращается новый: setTasks(tasks.toSpliced(index, 1)). Он уместен, когда индекс уже известен, — например, при удалении по позиции в списке.

Замок на состояние

В компоненте два обработчика мутируют состояние, и компилятор молчит. Измените только типы так, чтобы обе строки с мутацией стали ошибками сборки, а обработчик addGood продолжил компилироваться.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Два замка на двух уровнях: readonly у массива против push и readonly у полей против присваивания.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Компилятор ответит двумя диагностиками: «Свойство "push" не существует в типе "readonly Task[]"» и «Не удается задать значение для "done", так как это свойство, доступное только для чтения». addGood цел: spread собирает новый обычный массив, а он подставляется вместо readonly. Вместо пометок в интерфейсе можно оставить Task как есть и написать useState<ReadonlyArray<Readonly<Task>>> — тот же замок, собранный из утилит части III.