JSX — это не HTML

зачем React≈ 10 минут

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

Разметку внутри компонента почти все читают как HTML: те же теги, те же атрибуты. Но это не HTML и даже не строка — JSX — это синтаксис для вызова функции. Компилятор переписывает вашу разметку в обычный JavaScript, и почти все загадки первых дней в React объясняются тем, что именно у него получилось.

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

В части II вы собирали узлы руками: document.createElement("li"), затем li.className = "item", затем append. JSX не отменяет этот шаг, а прячет его на один уровень глубже: разметку читает компилятор и переписывает её в вызовы функций ещё до того, как код попадёт в браузер.

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

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

Компилятор превратил строку разметки в вызов React.createElement("h1", { className: "title" }, "Привет"). Имя тега стало первым аргументом, атрибуты собрались в объект props, содержимое уехало в props.children. Возвращается из вызова простой объект — его даже JSON.stringify печатает без труда. Смысл несут три поля: type, key и props. _owner и _store — служебные: их добавляет отладочная сборка React, которая стоит в песочнице, ради своих предупреждений. В боевой сборке их нет, зато там видно "ref":null — след старого API: с React 19 ref передают обычным пропсом, а отладочная сборка это поле от перечисления прячет. И одного поля в выводе нет ни в какой сборке — $$typeof: его значение символ, а символы JSON.stringify пропускает. По этому символу React и отличает настоящий элемент от объекта, который просто похож на него.

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

Элемент — это описание, а не кусок страницы. Пока его не передали в render, в документе не появилось ни одного узла.

Отсюда следует главное свойство JSX, которого у HTML нет: разметка — это выражение. Её можно положить в переменную, вернуть из функции, передать аргументом, сложить в массив. Что можно писать внутри фигурных скобок — тема следующей статьи; пока достаточно помнить, что снаружи это обычное значение.

С React 17 компиляторы — SWC в Next.js, Babel в Vite и Expo — включают «новый JSX-трансформ»: вместо React.createElement подставляется функция jsx из модуля react/jsx-runtime, который импортируется автоматически. Поэтому в файлах Next.js вы не увидите ни строчки import React, хотя разметка там есть.

Идея не меняется — вызов функции, возвращающей объект-описание. Меняется имя функции и то, что дети переезжают внутрь пропсов:

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

В песочнице учебника включён классический трансформ, поэтому в примерах виден именно React.createElement. Это удобнее: функция глобальная, и её можно вызвать руками рядом с JSX, чтобы сравнить результат. Как один и тот же файл выглядит после обоих трансформов и что ещё компилятор убирает из TSX, разобрано в главе про сборку.

Раз атрибуты собираются в объект, их имена становятся ключами этого объекта — то есть кодом на JavaScript. Тут и вылезает язык: class и for — зарезервированные слова. React не стал выдумывать новые имена, а взял те, которые вы уже знаете из статьи «Атрибуты и свойства»: у узла DOM эти свойства называются className и htmlFor. По той же логике все составные имена пишутся в camelCase, как свойства DOM.

Прежде чем жать Run, решите две вещи: сколько диагностик выдаст компилятор — и что произойдёт, если нажать на кнопку в кадре.

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

Диагностик две, и обе с готовым ответом: «Свойство "class" не существует… Вы имели в виду "className"?» и такое же про onclick → onClick. А дальше начинается неприятное. Кнопка нарисовалась, выглядит правильно — и на нажатие не отвечает.

Причина в том, что эти две опечатки ведут себя по-разному. Незнакомый пропс React 19 просто ставит атрибутом, поэтому class="panel" в разметке всё-таки оказался и стиль бы применился. А всё, что начинается на on, React разбирает сам и чужого не пропускает: onclick не стал ни обработчиком, ни атрибутом — он исчез.

Без типов такая опечатка не даёт ни ошибки, ни предупреждения в сборке: кнопка просто не работает. Это ровно тот случай, ради которого стоило проходить часть III.

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

Компилятор молчит, а кнопка в кадре печатает «сработало» на каждое нажатие. Разница между двумя примерами — три символа.

В HTMLВ JSXПочему так
classclassNameclass — зарезервированное слово; так же называется свойство узла DOM
forhtmlForто же самое: for занят циклом
onclickonClickсобытия — camelCase, а значение это функция, а не строка кода
tabindex, readonlytabIndex, readOnlyсоставные имена берутся у свойств DOM
maxlength, colspanmaxLength, colSpanто же правило, без исключений
data-id, aria-labelбез измененийу этих двух семейств дефисы сохраняются — их React отдаёт в DOM как есть

Таблицу заучивать не нужно. Правило простое: имя атрибута из HTML переводится в camelCase, кроме data-* и aria-*, — а на остальное компилятор ответит подсказкой «Вы имели в виду…».

Требование «у разметки должен быть один корень» выглядит произволом ровно до того момента, пока не вспомнить, во что превращается JSX. Каждый тег — это вызов функции, а return отдаёт одно значение. Два соседних тега — это два вызова, и вернуть их разом не из чего.

Компилятор на такое ругается. Но, как и все ошибки типов, эта не отменяет выполнение — код всё равно запустится. Что окажется в кадре?

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

«Выражения JSX должны иметь один родительский элемент» — и в кадре только абзац. Заголовок исчез бесследно, хотя вы его написали.

Компилятор не бросает разбор на первой ошибке, а старается выдать хоть что-то. Два выражения подряд он склеил оператором «запятая» — а тот вычисляет оба операнда и возвращает последний. Получилось буквально вот это:

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

Урок шире, чем про JSX: диагностика компилятора — это не «сборка не прошла». Код с ошибкой типов доезжает до браузера и делает что-то своё, обычно бессмысленное.

Лечится это либо обёрткой — <div>, <section>, что уместно по смыслу, — либо фрагментом, если лишний узел в разметке не нужен. Фрагмент пишется пустой парой скобок и не оставляет в документе ничего.

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

Пустая форма <>…</> не принимает ни одного пропса. Как только фрагменту понадобится key — в статье про списки, — придётся писать длинную форму <React.Fragment key={id}>.

Разборщик JSX устроен как разборщик XML: у каждого открытого тега обязан быть конец. Привычка писать <br>, <img> и <input> без закрытия пришла из HTML, где браузер держит в голове список пустых элементов. У компилятора такого списка нет — и он честно ищет закрывающий тег дальше по тексту.

Предскажите, что напечатает пример: важно не то, что будет диагностика, а что окажется в детях абзаца.

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

Диагностика говорит: «Элемент JSX "br" не содержит соответствующий закрывающий тег». А в выводе видно последствие: слово «Вторая» стало ребёнком <br>, а не соседом. Это не формальная придирка к слешу — разборщик прочитал вашу разметку иначе, чем вы её задумали, и структура получилась другая.

Отрисовать такое к тому же нельзя: React бросит ошибку выполнения, потому что у <br> детей не бывает. Со слешем структура становится той, которую вы имели в виду, — три ребёнка подряд.

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

Самозакрывающаяся форма законна для любого тега: <div /> — это просто пустой div. А вот <div> без пары — всегда ошибка, даже если визуально «и так понятно».

В HTML значение атрибута — всегда строка, даже когда там число или «да/нет». В JSX значение в фигурных скобках — обычное выражение JavaScript со своим настоящим типом. Отсюда два места, где перенесённая вёрстка ведёт себя не так, как ожидалось.

Первое — style. Это не строка с CSS, а объект: ключи в camelCase, значения — то, что положено свойству. Прежде чем запускать, решите, во что превратится padding: 6.

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

В кадре получается padding: 6px и border-radius: 4px: числу React сам приписывает пиксели. Исключение — свойства, у которых единиц нет: lineHeight, zIndex, opacity, flexGrow остаются голыми числами. Двойные скобки, которые всех смущают, — это просто объект внутри фигурных скобок значения.

Строку в style передавать нельзя: React бросит ошибку выполнения. В настоящем проекте её поймает компилятор, но в нашей песочнице типы CSS-свойств урезаны, и единственным сигналом будет запуск.

Второе место — логические атрибуты. В HTML флаг включается самим фактом присутствия атрибута, поэтому disabled="false" поле блокирует. В JSX значение живёт по правилам JavaScript.

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

Компилятор: «Тип "string" не может быть назначен для типа "boolean | undefined"». А поле в кадре заблокировано — непустая строка истинна, и React выставил атрибут. Правильная запись — disabled={false}: тогда React вообще не поставит атрибут, и поле останется рабочим.

Типичная задача первой недели: верстальщик прислал кусок HTML, его надо вставить в компонент. Вставленный дословно, он почти никогда не компилируется — и хорошо, потому что часть поломок иначе всплыла бы только у пользователя.

Что в HTMLЧто писать в JSX
class, for, tabindexclassName, htmlFor, tabIndex — и дальше camelCase
onclick="send()"onClick={send} — функция, а не строка кода
style="color: red"style={{ color: "red" }} — объект, ключи camelCase
<br>, <img>, <input><br />, <img />, <input />
два тега подряд на верхнем уровнеобёртка или фрагмент <>…</>
<!-- комментарий -->{/* комментарий */}
&nbsp; и другие сущностиработают как в HTML, править не нужно
innerHTMLdangerouslySetInnerHTML — и лишний раз подумать

Комментарий стоит разобрать отдельно: <!-- --> компилятор не понимает вовсе — это не разметка, а мусор посреди выражения, и пример с ним даже не запустится. Комментарий в JSX — это обычный комментарий JavaScript внутри фигурных скобок, и в дереве от него не остаётся следа.

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

Иногда HTML действительно приходит готовой строкой — из редактора, из статьи, из ответа сервера. Аналог innerHTML в React есть, и назван он так, чтобы вы задумались: dangerouslySetInnerHTML. Значение — объект с единственным ключом __html.

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

Опасность ровно та же, о которой шла речь в статье «textContent, innerText и innerHTML»: чужая строка, вставленная как разметка, может привезти с собой исполняемый код. Своей вёрстке этот приём не нужен — для неё есть JSX.

И последнее, что снимает половину вопросов: JSX не понимает ничего, кроме известных ему имён тегов и пропсов. Тег с маленькой буквы он считает элементом DOM и ищет в списке стандартных, а с большой — вашим компонентом. Поэтому <card /> даст «Свойство "card" не существует в типе "JSX.IntrinsicElements"», а <Card /> — то, что вы хотели.

  • JSX — синтаксис для вызова функции: компилятор превращает разметку в React.createElement, а тот возвращает обычный объект с полями type и props.
  • Атрибуты становятся ключами пропсов, поэтому class → className, for → htmlFor, а составные имена — camelCase; дефисы сохраняют только data-* и aria-*.
  • Опечатка onclick вместо onClick ничего не ломает на вид: разметка отрисуется, а обработчик молча пропадёт — ловит это только компилятор.
  • return отдаёт одно значение, поэтому у разметки один корень: обёртка или фрагмент <>…</>. Иначе останется только последний тег.
  • Каждый тег закрывается: <br> без слеша разборщик читает иначе, чем вы задумали, — следующий текст становится его ребёнком.
  • Значения — настоящие типы, а не строки: style это объект с camelCase-ключами, числа получают px, а disabled="false" поле блокирует.

1/4В переменную положили <h1 className="title">Привет</h1>. Что в ней окажется?

2/4Компонент возвращает два соседних тега без обёртки: <h1> и <p>. Компилятор ругается, но код всё равно выполняется. Что окажется на странице?

3/4Почему в JSX пишут className, а не class?

4/4Что произойдёт с полем, если написать <input disabled="false" />?

Перенести вёрстку в компонент

Форму скопировали из готовой HTML-страницы как есть. Компилятор выдаёт на неё пять диагностик. Почините разметку, ничего не убирая: должны остаться и подпись, и поле, и кнопка, а нажатие на кнопку — печатать «отправлено».

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

Четыре имени переводятся в camelCase-версии (class → className, for → htmlFor, tabindex → tabIndex, onclick → onClick), а <input> закрывается слешем. Значение tabIndex — число, поэтому оно уезжает в фигурные скобки.

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

Обратите внимание, какая из пяти правок была по-настоящему опасной: без неё компилятор молчал бы, форма выглядела бы правильно, а кнопка не работала. Это onclick.

Два корня без лишнего узла

Компонент должен вывести заголовок и абзац. Сейчас в кадре виден только абзац. Почините его так, чтобы в документе не появилось дополнительной обёртки: <div> добавлять нельзя.

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

Нужен фрагмент: пустая пара скобок даёт один корневой элемент для компилятора и ничего не оставляет в документе.

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

Проверить, что обёртки правда нет, можно инспектором: внутри контейнера лежат h1 и p как соседи. Фрагмент при этом самый обычный элемент — <>…</> компилируется в React.createElement(React.Fragment, null, …).

Тот же элемент без JSX

Соберите вручную такой же элемент, какой получается из разметки, — вызовом React.createElement. Сравнение в конце должно печатать true.

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

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

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

Полезно один раз сравнить и целиком: console.log(JSON.stringify(fromJsx)) печатает {"type":"li","key":null,"props":{"className":"item","id":"first","children":"Первый"},"_owner":null,"_store":{}}. Дети — не отдельное поле элемента, а такой же пропс, как className.