В этой статье мы поговорим о ключах (key) в React, разберемся зачем они нужны, как работает компонент при изменении ключа, и обсудим случаи, когда использование ключей вне массива или использование индексов в качестве ключей может быть даже лучше, чем уникальные значения.

Свойства key и стандартное использование

Использование ключей в массиве

В React ключи играют важную роль в процессе определения, какие компоненты были изменены. Если мы добавляем или удаляем элементы из массива компонентов без указания уникальных ключей, React будет перерисовывать все компоненты начиная с измененного элемента. Для лучшего понимания, рассмотрим пример на JavaScript.

Вот пример создания массива элементов в React с использованием уникальных ключей:

import React from 'react';

export const MyComponent = () => {
  const data = [
    { id: 1, name: 'Item 1' },
    { id: 2, name: 'Item 2' },
    { id: 3, name: 'Item 3' },
    { id: 4, name: 'Item 4' }
  ];
  
  return (
    <div>
      {data.map(item => (
        <div key={item.id}>{item.name}</div>
      ))}
    </div>;
};

export default MyComponent;

В этом примере мы создаем массив элементов data, каждый из которых имеет уникальный идентификатор id. Затем мы используем метод map для преобразования массива data в массив элементов JSX. Каждому элементу мы присваиваем уникальный ключ, которым в данном случае является значение id из соответствующего элемента.

В итоге мы получаем массив JSX элементов <div>, каждый из которых содержит текстовое значение имени элемента. Обратите внимание, что ключи используются для определения уникальности каждого элемента в массиве и обеспечения эффективного процесса обновления компонентов при необходимости.

Возьмем пример посложнее.

Здесь реализовано добавление элемента в конец списка и удаление элемента откуда угодно.

import React, { useState, memo, useCallback, useEffect } from "react";

type EditableListItemType = {
  text: string;
  id: number;
};

type EditableListItemProps = {
  id: number;
  text: string;
  onRemove: (id: number) => void;
};

export const EditableListItem = memo<EditableListItemProps>(
  ({ id, text, onRemove, ...props }) => {
    useEffect(() => {
      return () => {
        console.log("размонтирую", id);
      };
    }, []);
    console.log(id, text);
    return (
      <li>
        {text}
        <button onClick={() => onRemove(id)}>Удалить</button>
      </li>
    );
  }
);

let i = 0;

export const EditableList = () => {
  const [items, setItems] = useState<EditableListItemType[]>([]);

  const handleAddItem = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const form = e.target as HTMLFormElement;
    const input = (form.name as unknown) as HTMLInputElement;
    const text = input.value;
    if (text !== "") {
      const newItem = {
        id: ++i,
        text
      };
      setItems([...items, newItem]);
      form.reset();
    }
  };

  const onRemove = useCallback((id: number) => {
    setItems((v) => v.filter((item) => item.id !== id));
  }, []);

  return (
    <div>
      <form onSubmit={handleAddItem}>
        <input name="name" type="text" />
        <button type="submit">Добавить</button>
      </form>
      <ul>
        {items.map((item) => (
          <EditableListItem
            key={item.id}
            id={item.id}
            text={item.text}
            onRemove={onRemove}
          />
        ))}
      </ul>
    </div>
  );
};

Поиграться с этим примером можно здесь.

В данном случае, если мы будем использовать в качестве ключа индекс массива, при удалении мы увидим следующую картину.

Что происходит? Элементов в массиве стало меньше, но у элементов с 1-го по 3-й ключ остался один и тот же, а вот значение id и text изменилось, поэтому обновляются все последующие элементы, и удаляется не первый элемент, а последний, именно поэтому мы видим "размонтирую 4".

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

Что происходит с компонентом при изменении ключа

При изменении ключа компонент будет размонтирован, а затем снова смонтирован. Это позволяет React правильно обрабатывать изменения ключа и обновлять компонент соответствующим образом.

Ниже приведен академический пример, в котором можно убедиться в верности утверждения.

import React, { useReducer, useEffect } from "react";

const KeyWork = () => {
  useEffect(() => {
    console.log("Компонент KeyWork монтируется");
    return () => {
      console.log("Компонент KeyWork размонтируется");
    };
  }, []);

  return <div>Компонент KeyWork</div>;
};

export const KeyWorkParent = () => {
  const [count, increment] = useReducer(v => v + 1, 0);

  return (
    <div>
      <button onClick={increment}>Увеличить счетчик</button>
      <KeyWork key={count} />
    </div>
  );
};

Поиграться с этим примером можно тут.

Данное свойство можно использовать для перезапуска компонентов, об этом ниже.

Нестандартное использование key, основанное на свойствах  

Обновление компонента с помощью key вне массива

Интересный способ использования ключей в React заключается в использовании их для обновления компонента, находящегося вне массива. Например, при работе с компонентом обратного отсчета.

import React, { FC, useReducer, memo } from "react";
import "./TimeLimit.css";

export interface TimeLimitIndicatorProps {
  onEnd?: () => void;
}

export const TimeLimitIndicator = memo<TimeLimitIndicatorProps>(({ onEnd }) => {
  return (
    <div className="time-limit">
      <div className="time-limit__indicator" onAnimationEnd={onEnd} />
    </div>
  );
});
export const TimeLimit: FC = () => {
  const [count, increase] = useReducer((v) => v + 1, 0);
  return (
    <div>
      <button onClick={increase}>next</button>

      <TimeLimitIndicator key={count} onEnd={console.log} />
    </div>
  );
};

Выглядит это так

Поиграться можно тут.

Минимум кода, максимум радости!

Однако радость может быстро закончиться, когда попробуете таким способом управлять несколькими компонентами.

У вас уже может возникнуть желание плюнуть на все, ну его, это способ, что за фантастический баг! Лично у меня было такое желание, когда я впервые с таким столкнулся. Однако не торопитесь, все просто.

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

Чтобы решить эту проблему, можно добавить префиксы к ключам компонентов или обернуть каждый компонент в дополнительный HTML-элемент, чтобы ключи были уникальными в рамках родителя.

<TimeLimitIndicator key={count} onEnd={console.log} />
<TimeLimitIndicator key={`${count}1`} onEnd={console.log} />
<TimeLimitIndicator key={`${count}2`} onEnd={console.log} />
<TimeLimitIndicator key={count} onEnd={console.log} />
<div>
  <TimeLimitIndicator key={count} onEnd={console.log} />
</div>
<div>
  <TimeLimitIndicator key={count} onEnd={console.log} />
</div>

Использование ключей для перемонтирования компонента делает код проще и позволяет легко сбросить, например, состояние скролла, анимаций и других свойств элементов.

Когда индекс массива может быть использован в качестве ключа

Мы добрались наконец до самого крамольного раздела этой статьи, я уже вижу, как многие готовятся кидаться помидорами, но, уверяю вас, дело говорю.

Посмотрите этот код.

Это упрощенная версия тренажера.

import React, { useState, memo } from "react";

type KeyAsIndexProps = {
  active: boolean;
  index: number;
};

export const KeyAsIndexItem = memo<KeyAsIndexProps>(({ active, index }) => {
  console.log("обновился", index);
  return (
    <li
      className={[
        "KeyAsIndexExample__Item",
        active && "KeyAsIndexExample__Item_active"
      ]
        .filter(Boolean)
        .join(" ")}
    />
  );
});

export const KeyAsIndexExample = () => {
  const [items, setItems] = useState<boolean[]>([]);

  const getItems = (count: number) =>
    Array(count < 0 ? 0 : count)
      .fill("")
      .map(() => Math.random() > 0.5);

  const onAdd = () => {
    setItems((v) => getItems(v.length + 1));
  };

  const onRemove = () => {
    setItems((v) => getItems(v.length - 1));
  };

  return (
    <div>
      <button onClick={onRemove}>-</button>
      <button onClick={onAdd}>+</button>
      <ul className="KeyAsIndexExample">
        {items.map((item, i) => (
          <KeyAsIndexItem key={i} index={i} active={item} />
        ))}
      </ul>
    </div>
  );
};

Выглядит так

Здесь элементы массива никак не сортируются и при изменении длины массива React добавляет/удаляет новые элементы в конец. Генерация уникальных ключей избыточна, она только усложнит код и потребует дополнительных вычислений, при этом никак не улучшит работу программы. Элементы массива будут изменяться только при изменении пропса active, которое не зависит от уникального ключа.

Итоги

Когда элементы в массиве могут быть удалены/добавлены в любое место или когда элементы массива могут быть отсортированы - использование ключей обязательно. В противном случае, если элементы добавляются/удаляются в конец массива и сортировка не предполагается и нет заготовленных id, использовать индексы будет лучшим решением.

Изменение ключа размонтирует-монтирует компонент, благодаря этому свойству можно его удобно обнулять (анимации, скролл, прочее). Отсюда следует, что ключи можно использовать вне массивов. Но, если нужно обнулять несколько элементов, сделайте каждому уникальный ключ или поместите их в разные родительские элементы.

С ключами, разобрались. Напоследок хочу пригласить вас на бесплатный вебинар, где мой коллега расскажет про новый подход к организации фронтенда, а именно про SSR и серверные компоненты. Регистрируйтесь, ждем всех.

Комментарии (0)