Главная / Статьи

Ключи React как идентификатор компонента: сохранение и сброс состояния

Ключи React определяют, какая инстанция компонента останется после отрисовки. Как индексные ключи приводят к утечке состояния между строками и как изменение ключа намеренно сбрасывает форму.

Издатель: REACTAPP.TOP6 мин чтения1462 слов

Источники: React Keys Solve a Much Stranger Problem Than Rendering Lists

Большинство разработчиков React добавляют атрибут key к каждому вызову .map() без особых размышлений, и большинство объяснений ограничиваются фразой «React нуждается в нем для отслеживания изменений элементов». Этот ответ верен, но неполон. На самом деле React при каждой отрисовке задает вопрос: является ли элемент, который он видит сейчас, тем же самым элементом, что и в прошлый раз? Атрибут key — это способ ответить на этот вопрос. Как только вы начнёте рассматривать ключи как механизм идентификации, ошибки, связанные с использованием индексов в качестве ключей, станут предсказуемыми, а вы получите чёткий, декларативный способ сброса состояния компонента по требованию.

Рефлексивная версия

Вот шаблон, присутствующий практически в каждом кодовом базе. Каждый пользователь из массива превращается в элемент User, а его идентификатор служит ключом:

users.map((user) => (
  <User key={user.id} user={user} />
))

В этом фрагменте кода нет ничего не так. Интересно то, что происходит при плохом выборе ключа, и это становится заметно только тогда, когда компоненты хранят собственное состояние.

Состояние компонента делает проблему видимой

Рассмотрим компонент-карточку, который хранит локальный флаг selected. Этот флаг находится внутри компонента, а не в свойстве user, поэтому React должен определять, какая карточка владеет тем или иным флагом при каждой перерисовке:

interface UserCardProps {
  user: User;
}

const UserCard = ({ user }: UserCardProps) => {
  const [selected, setSelected] = useState(false);

  return (
    // ...
  );
}

Теперь отрисуем трех человек: Элис, Боба и ещё одного пользователя. В виде пар индекс-элемент массив выглядит так:

0 → Alice
1 → Bob
2 → Maxime

Что видит React при использовании индексов в качестве ключей

Обычно советуют избегать использования индексов массива в качестве ключей. Причина этого становится понятной, если посмотреть на элементы, которые на самом деле получает React при использовании индекса:

<User key={0} user={alice} />
<User key={1} user={bob} />
<User key={2} user={maxime} />

Предположим, что список поддерживает удаление элементов, их добавление или перестановку с помощью перетаскивания. Если убрать Элис, при следующем отрисовывании появится следующее:

<User key={0} user={bob} />
<User key={1} user={maxime} />

С вашей точки зрения, Элис исчезла, а два остальных элемента сместились вверх: Боб переместился с индекса 1 на индекс 0, а третий пользователь — с 2 на 1. С точки зрения React нет никаких сигналов, указывающих на это. Он видит, что элемент с key={0} существовал ранее и по-прежнему существует, причем тип компонента остался тем же. Тот же тип вместе с тем же ключом — именно этот сигнал используется React для принятия решения о том, следует ли сохранить данный экземпляр. Поэтому React сохраняет экземпляр, который раньше отображал Элис, и передаёт ему атрибуты Боба; сохраняет второй экземпляр и передаёт ему атрибуты третьего пользователя; затем деинсталлирует третий экземпляр.

Последствие: если была выбрана карта Элис, теперь как будто выбрана карта Боба, поскольку состояние selected принадлежало экземпляру с ключом 0, а не Элис. Подобные утечки влияют на неконтролируемые данные ввода, фокус, положение прокрутки внутри строки и любые другие состояния, связанные с экземпляром.

Почему эти ошибки ускользают

Неправильные ключи удивительно часто встречаются в реальных приложениях любого размера. Список, к которому постоянно добавляют элементы и порядок в котором никогда не меняется, ведет себя одинаково как с индексными ключами, так и со стабильными ключами, поэтому ничего не кажется неправильным. Ошибка проявляется позже, когда добавляются функции удаления, фильтрации, сортировки или переупорядочивания. Инструменты проверки кода помогают: правило react/no-array-index-key из eslint-plugin-react выделяет индексные ключи, что позволяет принимать решения осознанно, а не по привычке.

Когда у элемента действительно нет стабильного идентификатора, его следует генерировать при создании данных (например, с помощью crypto.randomUUID() в момент вставки), а не во время отрисовки. Ключ, сгенерированный внутри .map(), меняется при каждой отрисовке, что заставляет React перерисовывать каждую строку заново, что хуже, чем использование индекса.

Props описывают содержимое, ключи — идентичность

Здесь поможет простая модель мышления:

  • props отвечают на вопрос «Что сейчас содержит этот элемент?»
  • ключ отвечает на вопрос «Что это за элемент?»

Props могут свободно меняться, в то время как компонент остается той же инстанцией с нетронутым состоянием. Если строка поддерживает редактирование внутри нее, изменение имени пользователя не должно приводить к тому, что React удаляет эту строку и начинает всё сначала; это по-прежнему тот же пользователь. Стабильный ключ, такой как user.id, именно это и обеспечивает.

Умышленное создание новой инстанции

Иногда нужно наоборот: вы хотите, чтобы React рассматривал компонент как совершенно новый. Ключ также работает вне списков:

<Profile key={user.id} user={user} />

Представьте, что Profile хранит несохраненный черновик в локальном состоянии:

interface ProfileProps {
  user: User;
}

const Profile = ({ user }: ProfileProps) => {
  const [draft, setDraft] = useState({...});
}

Если родительский элемент переключится с одного пользователя на другого, сохранение черновика предыдущего пользователя в следующем профиле станет настоящей ошибкой, возможно, связанной с утечкой данных. Без ключа React видит один и тот же тип компонента в одном и том же месте и сохраняет инстанцию, поэтому черновик остается. С использованием key={user.id} новый идентификатор сообщает React, что старый Profile больше не существует. Он демонтирует старую инстанцию, удаляет её состояние, выполняет операции очистки эффектов и монтирует новую, при этом инициализаторы useState снова запускаются.

Это идиоматичная альтернатива написанию эффекта, который отслеживает user и вручную сбрасывает каждый элемент состояния. Подход с использованием эффектов приводит к одной отрисовке с устаревшим состоянием перед его корректировкой, к тому же легко забыть о каком-либо поле. Чтобы узнать больше о том, почему синхронизация состояния через эффекты часто приводит к обратному эффекту, ознакомьтесь с более безопасным подходом, заменяющим синхронизацию на основе useEffect.

Сброс формы с использованием ключа

Тот же прием позволяет эффективно сбрасывать формы:

<CreateUserForm key={draft.id} />

Ключ служит границей идентичности. Каждый раз, когда меняется draft.id, например после успешной отправки, которая создает новый черновик, React формирует новый экземпляр CreateUserForm без остаточных значений полей, ошибок валидации или флагов изменения.

Следует учитывать два важных момента. Повторная установка сбрасывает всю ветвь дерева, включая дочерние элементы и всё, что они загрузили при первой установке, поэтому используйте её для компонентов, где действительно требуется полная перезагрузка. Кроме того, это влечёт за собой полную демонтировку и установку, что подходит для форм, но не является оптимальным решением при каждом нажатии клавиши.

Где на самом деле хранится состояние

Всё это следует из того, как React хранит состояние. Состояние не привязано к функции, которую вы написали; если бы это было так, каждый элемент <User /> имел бы одинаковое значение selected. Вместо этого React связывает состояние с позицией в отрисованной структуре. При каждой отрисовке он проходит по новому результату и для каждого элемента определяет, является ли он продолжением чего-то из предыдущей отрисовки. Тип компонента и его позиция входят в состав этого решения, а ключ уточняет его значение среди братьев по элементу. Статья о том, где useState сохраняет своё значение более подробно рассматривает структуру fiber, лежащую в основе этого процесса.

Вытекающее из этого правило простое:

  • одинаковый тип, одна и та же позиция, один и тот же ключ: React считает это одним и тем же компонентом и сохраняет его состояние
  • другой тип или другой ключ: React удаляет старое состояние и создает новый экземпляр
  • Списки — это первое место, где порядок элементов перестаёт быть надёжным. Когда элементы могут перемещаться, появляться или исчезать, выражение «второй элемент» уже не означает «то же самое, что и раньше», поэтому React требует указания идентификатора. Стабильный ключ позволяет ему отслеживать элемент даже при изменении порядка списка; изменённый ключ является чётким сигналом о том, что на его место пришёл новый элемент.

    Основные выводы

    • Ключ указывает React, какой именно экземпляр элемента это, а не просто какой элемент изменился.
    • Индексные ключи связывают состояние с конкретным слотом, поэтому удаление или перестановка элементов приводит к тому, что состояние попадает не в те строки.
    • Используйте идентификаторы, которые остаются стабильными при каждом отрисовывании; никогда не генерируйте ключи во время отрисовки.
    • Целенаправленная замена ключа — это самый эффективный способ сброса составляющей, такой как форма или редактор для отдельной записи.
    • Используйте сброс по ключу осторожно: это приводит к повторной загрузке всего поддерева и удаляет все, что находится внутри него.