Ты неправильно используешь key

5 мин чтения
  • React
  • key
  • Состояние
  • Архитектура

Есть вещи в React, которые все используют, но мало кто понимает до конца. key — одна из них.

Большинство знакомятся с ним так: пишешь .map(), в консоли появляется предупреждение “Each child in a list should have a unique key prop”, добавляешь key={index} — варнинг пропадает, жизнь продолжается.

Но key — это не про списки. Это про идентичность компонента. И как только это понимаешь, открывается целый арсенал трюков.


Как React решает, обновить компонент или пересоздать

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

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

// React видит тот же <UserCard> на том же месте → обновляет пропсы, стейт сохраняется
<UserCard userId={1} />
<UserCard userId={2} /> // просто изменились пропсы

// React видит другой key → размонтирует первый, монтирует второй
<UserCard key="user-1" userId={1} />
<UserCard key="user-2" userId={2} /> // новый экземпляр, стейт сброшен

Именно это свойство делает key таким мощным инструментом.

Схема: слева — дерево без key (компонент обновляется), справа — с разными keys (компонент пересоздаётся)


Трюк №1: сброс состояния через key

Представь классическую ситуацию: форма редактирования профиля. Пользователь открыл профиль Алисы, начал что-то менять, потом переключился на профиль Боба — и увидел несохранённые данные Алисы в полях.

function ProfileEditor({ userId }) {
  const [name, setName] = useState('');
  const [bio, setBio] = useState('');

  useEffect(() => {
    fetchUser(userId).then(user => {
      setName(user.name);
      setBio(user.bio);
    });
  }, [userId]);

  return (
    <form>
      <input value={name} onChange={e => setName(e.target.value)} />
      <textarea value={bio} onChange={e => setBio(e.target.value)} />
    </form>
  );
}

Стандартное решение — сбрасывать стейт в useEffect при смене userId. Но это приводит к лишнему рендеру: сначала компонент рендерится со старыми данными, потом эффект запускается и обнуляет их.

Элегантное решение — просто добавить key:

// В родительском компоненте
<ProfileEditor key={userId} userId={userId} />

Теперь при смене userId React видит новый key и полностью пересоздаёт компонент. Никакого лишнего рендера, никакого useEffect для сброса, никаких старых данных. Компонент стартует с чистого листа.

Слева — форма с данными Алисы (key=alice), справа — чистая форма после смены key (key=bob)

Это особенно удобно для:

  • Форм с несколькими режимами (создание / редактирование)
  • Компонентов с внутренними таймерами или анимациями
  • Любого компонента, который должен “не знать” о предыдущей жизни

Трюк №2: key вне списков

Вот что большинство не знает: key можно вешать на любой компонент, не только внутри .map().

// Это абсолютно легальный React
function App() {
  const [mode, setMode] = useState('dark');

  return (
    <ThemeProvider key={mode} theme={mode}>
      <Dashboard />
    </ThemeProvider>
  );
}

При смене mode весь поддерево под ThemeProvider будет пересоздано заново. Иногда это именно то, что нужно — особенно если внутри есть компоненты с состоянием, которое должно сброситься вместе с темой.

Другой пример: страницы в роутере. Если два роута рендерят один и тот же компонент, React по умолчанию не пересоздаёт его — просто меняет пропсы. Это может быть неожиданным:

// Переход между /users/1 и /users/2 НЕ пересоздаёт UserPage
<Route path="/users/:id" element={<UserPage />} />

// С key — пересоздаёт при каждой смене id
<Route
  path="/users/:id"
  element={<UserPage key={params.id} />}
/>

Диаграмма роутинга: без key стейт сохраняется, с key — сбрасывается при смене URL


Про уникальность: глобальная vs локальная

Распространённое заблуждение — key должен быть уникальным во всём приложении. Это не так.

Key должен быть уникальным только среди соседних элементов. React смотрит на keys только в пределах одного родителя.

function App() {
  return (
    <>
      <ul>
        <li key="1">Первый список, элемент 1</li>
        <li key="2">Первый список, элемент 2</li>
      </ul>

      <ul>
        <li key="1">Второй список, элемент 1</li> {/* key="1" снова — и это нормально */}
        <li key="2">Второй список, элемент 2</li>
      </ul>
    </>
  );
}

React не перепутает эти элементы, потому что они находятся в разных родителях. Scope у key — локальный.

Это важно знать при генерации keys для вложенных структур. Нет смысла делать глобально уникальные идентификаторы — достаточно уникальности в пределах одного списка.

// ✅ Достаточно — id уникален в пределах категории
categories.map(category => (
  <div key={category.id}>
    {category.items.map(item => (
      <Item key={item.id} item={item} /> // item.id уникален внутри category.items
    ))}
  </div>
))

Key и анимации: подводный камень

Раз уж key вызывает размонтирование, он напрямую влияет на анимации. Это место, где многие обжигаются.

Допустим, у тебя есть компонент с анимацией появления и исчезновения через Framer Motion:

function Notification({ message, id }) {
  return (
    <motion.div
      initial={{ opacity: 0, y: -20 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -20 }} // exit-анимация
    >
      {message}
    </motion.div>
  );
}

Если ты меняешь key у этого компонента, React немедленно размонтирует его — exit-анимация не успевает сыграть. Компонент просто исчезает.

// ❌ exit-анимация не сыграет — компонент сразу исчезнет
<Notification key={notificationId} message={text} />

Решение — обернуть в AnimatePresence. Он перехватывает размонтирование и даёт компоненту доиграть exit-анимацию перед удалением из DOM:

// ✅ exit-анимация отработает корректно
<AnimatePresence mode="wait">
  <Notification key={notificationId} message={text} />
</AnimatePresence>

Слева — нотификация исчезает резко (без AnimatePresence), справа — плавно уезжает вверх (с AnimatePresence)

То же самое актуально для React Transition Group и любых других библиотек анимаций, которые работают с монтированием/размонтированием. Всегда проверяй, что смена key не убивает exit-анимацию.


Итог

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

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