Выражения, условия и списки в разметке
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В части II вы вставляли данные в разметку через ${…} шаблонной строки, а список собирали как map плюс join(""). В JSX ту же работу делают фигурные скобки и тот же map — только результат больше не превращается в текст, и у React появляется вопрос, которого у innerHTML не было: какая строка списка какой была до перерисовки. Ответ на него называется key.
В предыдущей статье выяснилось, что разметка — это выражение, а внутри неё есть окна обратно в JavaScript: фигурные скобки. В них допустимо любое выражение — обращение к переменной, арифметика, вызов метода, шаблонная строка. Компилятор подставляет его значение как отдельного ребёнка в вызов createElement, а не вклеивает текст в строку, как делал ваш render().
Четыре пары скобок — четыре выражения, каждое со своим типом: строка, число, снова строка, число. В текст они превратились только на самом последнем шаге, когда React создавал текстовые узлы. Ограничение ровно одно: внутри скобок должно стоять выражение. Инструкции — if, for, let — там не живут: у них нет значения, которое можно было бы подставить.
Компилятор останавливается на первом же символе после скобки: «Ожидалось выражение» — и добавляет ещё две претензии следом: разбор сбился и до конца строки уже не восстановился. В выводе к тому же SyntaxError: до браузера доехало то, что компилятор сумел собрать из обломков. Всё, что дальше в этой статье, — способы записать условие и цикл так, чтобы у них появилось значение. Но прежде нужно выяснить, что React делает со значениями разных типов.
null, undefined, true и false не рисуются вовсе — на их месте пусто, и это не ошибка. На этом правиле держится весь следующий раздел. Ноль — обычное число, и он рисуется как «0»: запомните это, через минуту оно выстрелит. А массив React разворачивает и рисует каждый элемент по очереди — без запятых между ними, в отличие от ${[1, 2, 3]} в шаблонной строке, где получилось бы «1,2,3». На этом держится раздел про списки.
Объект нарисовать нельзя, и это единственный тип значений, на котором React падает. Самая частая жертва — Date: {createdAt} вместо {createdAt.toLocaleDateString()}. В настоящем проекте до запуска дело не дойдёт: тип детей — ReactNode, и объекта среди его вариантов нет.
Прямо причину в панели не называет ни одна строка. Первая — сама ошибка, но её текст браузер от песочницы прячет: React подключён в неё отдельным скриптом с чужого происхождения, а у таких скриптов браузер вырезает сообщения исключений. Вторая строка — от отладочной сборки React: она называет компонент, в котором всё сломалось, — <time>. В настоящем проекте, где React собран вместе со страницей, консоль покажет и сам текст: Objects are not valid as a React child (found: [object Date]) — объект не может быть ребёнком. Боевая сборка React вместо текста печатает только номер ошибки, #31, и ссылку на его расшифровку.
Раз false рисуется как пустота, условие «показать, если» пишется через && — тот самый, что вы разбирали в части I: он возвращает левый операнд, если тот ложный, и правый в остальных случаях. Ложный левый операнд превращается в ничего, истинный — в элемент справа.
Приём настолько удобный, что его начинают писать не думая, — и ровно тут он подводит. Слева от && оказывается не логическое значение, а число. Прежде чем запускать, ответьте: что окажется в секции, если писем нет?
&& вернул левый операнд — ноль, а ноль, как выяснилось разделом выше, рисуется. Компилятор промолчал совершенно законно: число — полноправный ReactNode, ошибки типов здесь нет, есть ошибка смысла. Лечится она тем, что слева ставят настоящее условие — letters.length > 0. Правило простое: слева от && в разметке — только boolean.
Пустую строку React тоже пропускает, поэтому {name && <b>…</b>} со строкой слева выглядит рабочим. Это везение, а не правило: с числом слева тот же код напечатает ноль. Условие лучше писать явно.
Когда веток две, берут тернарный оператор: обе части — выражения, и результат тоже. А когда веток три и больше, условие незачем втискивать в скобки: JSX — значение, значит, его можно вычислить обычным if до return и подставить готовым.
| Задача | Запись |
|---|---|
| Показать или ничего | {cond && <X />}, где cond — строго boolean |
| Одно из двух | {cond ? <A /> : <B />} |
| Три ветки и больше | Переменная типа ReactNode и обычный if или switch до return |
| Компонент целиком не нужен | return null — React ничего не нарисует и не пожалуется |
Ранний return null удобен, но ставьте его после всех хуков: useState, вызванный ниже условия, нарушает правила хуков. Когда такой возврат падает с ошибкой, а когда молча теряет состояние, разбирает статья «Правила хуков и почему они такие» в главе 6.
В доске задач из части II список собирался в одну строку: tasks.map((t) => `<li>${t.title}</li>`).join(""), и эта строка уходила в innerHTML. В JSX остаётся только первая половина. Колбэк возвращает элемент, а не строку, join исчезает: массив в фигурных скобках React разворачивает сам, вы это видели на [1, 2, 3].
Три записи — три li, тернарный оператор из прошлого раздела ставит отметку. Ни строки, ни innerHTML, ни разбора HTML: React получил массив из трёх описаний и создал три узла. Но в настоящем проекте этот код не пройдёт молча. В режиме разработки React напечатает в консоль предупреждение: Each child in a list should have a unique "key" prop. В песочнице стоит та же отладочная сборка React, поэтому это предупреждение вы видите прямо в выводе примера выше: это не сбой песочницы, а та самая проверка режима разработки.
Исправление — атрибут key на том элементе, который возвращает колбэк. Значение должно быть уникальным среди соседей и постоянным от отрисовки к отрисовке. Идентификатор записи подходит идеально — тот самый id, который в части II вы записывали в data-id, чтобы обработчик клика узнавал строку. React нужен он же и по той же причине: узнавать строки.
Обратите внимание, где стоит key: на <Row> в вызове map, а не на <li> внутри Row. React смотрит на ключи тех элементов, что лежат в массиве; то, что компонент вернёт изнутри, к массиву уже не относится. И внутрь Row этот атрибут не попадает: key React забирает себе, а разговор о том, что попадает, — в статье «Пропсы и их типы».
Тип у ключа — string | number | bigint. Целую запись вместо её идентификатора компилятор не пропустит: ошибка нарочная, посмотрите, как он её сформулирует.
Иногда колбэк должен вернуть не один элемент, а пару соседей — dt и dd словаря, две строки таблицы. Лишний div тут не выход: внутри tbody он запрещён, в словаре просто не нужен. А короткий фрагмент <> не принимает атрибутов — для этого случая у фрагмента есть полная форма.
В статье о том, зачем нужен React, вы видели, как он сверяет старое описание с новым: по позиции и типу элемента. Пока список только растёт снизу, позиции стабильны, и всё сходится. Но стоит вставить запись в начало — и на каждой позиции оказывается другая запись. Что при этом произойдёт с состоянием строк, которое хранит useState?
Строка ниже — компонент со своим счётчиком. Пример нажимает «+1» у первой строки дважды, затем добавляет запись в начало. Ключ — индекс, как многие пишут, чтобы предупреждение замолчало. Запишите прогноз: у какой строки окажется двойка?
Двойка досталась «Воде», которую никто не нажимал. React увидел на позиции 0 тот же ключ 0 и тот же тип Row — значит, по его правилам, это та же строка, и её состояние надо сохранить. Изменился только проп title. Строка «Кофе» переехала на позицию 1, где раньше жил «Чай», — и получила его нулевой счётчик. Ни одной записи не удалили, а данные на экране перепутались — тот самый класс ошибок, ради которого вы уходили от innerHTML.
Ключ из записи, а не из позиции, меняет картину. Проверим заодно, что стало с узлами: запомним первый li до вставки и найдём его после.
Теперь React сопоставляет строки по ключам: ключ 1 был и остался — это «Кофе» со своим состоянием, ключ 3 новый — под него создаётся строка с чистым счётчиком. Узел li не перерисован и даже не пересоздан: React переставил его на новое место. Именно так ведёт себя список, который вы в части II могли получить только полной пересборкой.
key={index} — то же самое, что вообще без key: индекс React подставляет сам, когда ключа нет. Такой ключ глушит предупреждение и не чинит ошибку.
Ключ сравнивается только с ключами соседей по одному массиву. Два разных списка на странице спокойно используют одни и те же ключи, а вложенный список внутри строки начинает свой отсчёт заново. Глобально уникальный идентификатор не нужен; нужен уникальный внутри map.
Повтор ключа среди соседей — уже ошибка: в режиме разработки React сообщит «Encountered two children with the same key», а на бою может показать одну запись вместо двух или потерять состояние. Если в данных нет поля, которое гарантированно не повторяется, ключ придётся выдавать при создании записи.
Из требований «уникален среди соседей» и «не меняется между отрисовками» правила выбора выводятся почти механически.
| Откуда записи | Ключ |
|---|---|
Из базы или API — у каждой есть id | key={item.id}, и никаких раздумий |
| Есть поле, уникальное по смыслу: email, slug, код валюты | Это поле — оно не меняется и не повторяется |
| Создаются на клиенте: новая задача, новая строка формы | Идентификатор выдаётся в момент создания — счётчиком или crypto.randomUUID() — и хранится вместе с записью |
| Статичный список, который не сортируют, не фильтруют и не пополняют спереди | Индекс допустим: позиция и есть тождество записи |
Строка «в момент создания» в третьей клетке — самая важная. Случайный ключ, сгенерированный прямо в map, нарушает второе требование: он новый на каждой отрисовке. Для React это выглядит так, будто все старые записи удалили, а вместо них добавили другие. Прогноз: что станет со счётчиком строки «Кофе» после нажатия на «перерисовать»?
Данные не менялись, менялся только счётчик родителя — а строки потеряли состояние и получили новые узлы DOM, со всем, что к ним прилагается: фокусом, кареткой, прокруткой. Ровно те потери, что были у innerHTML, только теперь они спрятаны за атрибутом. crypto.randomUUID() внутри map — та же ошибка с более убедительным видом.
Этот же механизм используют нарочно: сменить key у компонента — значит попросить React пересоздать его с чистым состоянием. Подробно этот приём разбирает статья «Когда состояние сохраняется, а когда сбрасывается» в главе 5.
Итог по спискам умещается в одну привычку. Как только в коде появляется map с разметкой внутри, спросите себя: что в этих данных остаётся тем же, когда запись переезжает? Это и есть ключ. Если ответа нет — его нужно добавить в данные, а не выдумывать в отрисовке.
- Внутри фигурных скобок — любое выражение, но не инструкция: у if и for нет значения, которое можно подставить.
- null, undefined, true и false не рисуются вовсе; ноль рисуется как «0», массив разворачивается в список детей без разделителей.
- Условие «показать или ничего» — через && со строго логическим левым операндом; две ветки — тернарный оператор; больше — обычный if до return.
- Список — это map, возвращающий элементы; join и innerHTML исчезают, потому что массив в скобках React разворачивает сам.
- key нужен React, чтобы узнавать записи между отрисовками: без него состояние и узлы прилипают к позиции, а не к данным.
- Ключ берут из данных — id или уникальное поле; индекс годится только для неподвижного списка, а случайное число в map пересоздаёт всё при каждой отрисовке.
1/4В разметке стоит {messages.length && <p>Есть новые</p>}, а массив messages пуст. Что окажется на странице?
2/4Список строк с собственным useState в каждой отрисован с key={index}. У первой строки счётчик равен 2. В начало массива вставили новую запись. Где окажется двойка?
3/4Внутри map написано key={crypto.randomUUID()}. Автор считает, что так ключи точно уникальны. Что произойдёт при следующей отрисовке родителя?
4/4Чем отличается результат {["a", "b"]} в JSX от ${["a", "b"]} в шаблонной строке?
Ноль в корзине
Компонент показывает список покупок, а если корзина пуста — ничего. Сейчас при пустой корзине на странице появляется лишний символ. Найдите его в выводе и почините, не меняя структуру разметки.
Слева от && стоит число: при пустой корзине это 0, и React честно рисует его текстом. Условие должно быть логическим.
Тот же результат даст тернарный оператор с null во второй ветке: {items.length ? <ul>…</ul> : null}. Обратите внимание на ключ: названия товаров здесь уникальны, поэтому key={item} допустим — но если один товар можно положить дважды, ключом должен стать идентификатор записи.
Доска задач на map
Перед вами данные доски из части II. Напишите компонент Board, который рисует по li на задачу: отметка «[x]» или «[ ]», затем название. Выполненные задачи — курсивом через <i>, остальные обычным текстом. Ключ выберите из данных.
Отметка — тернарный оператор в скобках, курсив — ещё один тернарный оператор, у которого обе ветки возвращают разметку. Ключ — id: он есть в данных и не меняется.
Если второй тернарный оператор кажется тяжёлым, вынесите его в переменную до return или в отдельный компонент Row — тогда key переедет на <Row>.
Сортировка без потерь
В списке городов у каждой строки свой флажок «избранное» в состоянии. Кнопка сортирует список по алфавиту. Сейчас после сортировки отметка остаётся на первой позиции, а не у того города, который отметили. Пример нажимает всё за вас — исправьте ключ так, чтобы после сортировки избранным остался Дубай.
Индекс описывает позицию, а не город, поэтому после сортировки состояние «первой строки» досталось Абу-Даби. Ключом должно быть поле, которое остаётся с записью при любой перестановке. В данных есть код аэропорта — он уникален и не меняется.
Название тоже сработало бы, пока города не повторяются, но код надёжнее: два города с одинаковым названием — реальность, два одинаковых кода — нет. Индекс в качестве ключа годится только для списка, который никогда не переставляют.