Первое, что видит человек, открывший React-компонент, — HTML-теги прямо посреди JavaScript-функции. Выглядит так, будто кто-то перепутал файлы. На самом деле это JSX (JavaScript XML) — расширение синтаксиса JavaScript, придуманное для описания интерфейса. Похожесть на HTML здесь обманчивая: браузер такой код не поймёт, а сам JSX по правилам ближе к JavaScript, чем к разметке.

Разберёмся, что происходит с JSX до того, как код попадёт в браузер, как выглядел бы React вообще без него и на каких отличиях от HTML спотыкаются чаще всего.

JSX — это вызов функции в другой записи

Вот самая короткая строчка на JSX:

const title = <h1 className="page-title">Каталог</h1>;

Если вставить её в обычный <script>, браузер упадёт с SyntaxError: в спецификации JavaScript угловых скобок в таком месте нет. Поэтому перед запуском JSX всегда проходит через компилятор, который переписывает теги в обычные вызовы функций. В классической схеме каждый тег превращался в React.createElement(тип, пропсы, ...дети):

const title = React.createElement(
  'h1',                          // тип: строка для HTML-тега
  { className: 'page-title' },   // атрибуты превращаются в объект пропсов
  'Каталог'                      // всё, что внутри тега, — дети
);

Функция ничего не рисует. Она возвращает простой объект-описание — React-элемент. Упрощённо он выглядит так:

{
  type: 'h1',
  key: null,
  props: { className: 'page-title', children: 'Каталог' }
}

Из таких объектов React собирает дерево, сравнивает его с предыдущей версией и обновляет в реальном DOM только то, что изменилось. JSX в этой цепочке — лишь удобный способ эти объекты описать.

Автоматический рантайм: почему больше не пишут import React

В старых туториалах каждый файл с JSX начинается с import React from 'react', даже если React в коде больше нигде не используется. Причина в схеме выше: после компиляции в файле появляется React.createElement, и переменная React должна быть в области видимости.

В React 17 появился автоматический рантайм (automatic runtime), и сейчас компиляторы используют его по умолчанию: нужную функцию они импортируют сами из пакета react/jsx-runtime. Та же строчка компилируется вот так:

import { jsx as _jsx } from 'react/jsx-runtime';

const title = _jsx('h1', { className: 'page-title', children: 'Каталог' });

Дети здесь сразу лежат в props.children, а для элементов с несколькими детьми компилятор зовёт соседнюю функцию _jsxs. В React 19 автоматический рантайм обязателен: на нём работают ускорения JSX и ref как обычный проп, а со старой схемой React выводит предупреждение об устаревшем преобразовании. Так что import React в начале файла нужен только тогда, когда из React берутся хуки или другие экспорты, и даже их обычно импортируют поимённо: import { useState } from 'react'.

Кто компилирует JSX

JSX обычно связывают с Babel, но компиляторов несколько:

  • Babel — через пресет @babel/preset-react. В проектах на Webpack его подключают через babel-loader: сам Webpack JSX не понимает, он только передаёт файлы лоадеру.
  • TypeScript — файлы .tsx компилирует сам, режим задаётся опцией "jsx": "react-jsx" в tsconfig.json. Подробнее о том, зачем TypeScript фронтендеру, — в статье о переходе на TypeScript.
  • Vite — начиная с восьмой версии разбирает JSX компилятором Oxc, написанным на Rust. Плагин @vitejs/plugin-react шестой версии вообще перестал тянуть Babel в зависимости, а настройки рантайма переехали из блока esbuild в блок oxc.

Для того, кто пишет компоненты, разницы нет: на выходе у всех одинаковые вызовы _jsx. Посмотреть, во что превращается конкретный кусок, можно в онлайн-песочнице Babel REPL: слева пишется JSX, справа сразу виден результат.

Один компонент с JSX и без него

Раз JSX — только запись, React можно использовать и без него, вызывая createElement вручную. Сравним на карточке пользователя. Сначала версия на JSX:

function ProfileCard() {
  return (
    <article className="profile">
      <img src="/avatars/maria.png" alt="Фото Марии" />
      <h2>Мария Коваль</h2>
      <p>Фронтенд-разработчица</p>
      <ul>
        <li>Стек: React, TypeScript</li>
        <li>Город: Lviv</li>
      </ul>
    </article>
  );
}

И та же карточка на чистом JavaScript:

import { createElement as h } from 'react';

function ProfileCard() {
  return h(
    'article',
    { className: 'profile' },
    h('img', { src: '/avatars/maria.png', alt: 'Фото Марии' }),
    h('h2', null, 'Мария Коваль'),
    h('p', null, 'Фронтенд-разработчица'),
    h(
      'ul',
      null,
      h('li', null, 'Стек: React, TypeScript'),
      h('li', null, 'Город: Lviv')
    )
  );
}

Результат одинаковый, а читается второй вариант заметно хуже. Где заканчивается список и к какому родителю относится строчка с городом, приходится выяснять подсчётом скобок. Конкретные плюсы JSX:

  • Вложенность видна глазами. Структура кода повторяет структуру будущего DOM, как в обычной вёрстке.
  • Ошибки ловятся при сборке. Незакрытый тег или лишняя скобка ломают компиляцию сразу, а не превращаются в странный интерфейс в браузере.
  • Логика и разметка рядом. Условия, циклы и обработчики пишутся прямо в компоненте, без отдельного языка шаблонов.

Обходиться без JSX имеет смысл, когда сборки нет вообще — например, в небольшом скрипте на странице, который подключает React из CDN. Тогда createElement с коротким псевдонимом вроде h из примера выше — вполне рабочий вариант.

Строки в JSX экранируются сами

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

const userName = '<img src="x" onerror="alert(1)">';

// Выведется как текст, скрипт не выполнится
const greeting = <p>Привет, {userName}!</p>;

Это защищает от XSS (cross-site scripting, межсайтовый скриптинг) — атаки, при которой чужой код попадает на страницу через пользовательский ввод. Для сравнения: element.innerHTML = userName в обычном DOM: там та же строка превратится в живой тег. Вставить именно HTML в React можно только через проп с говорящим названием dangerouslySetInnerHTML, и подходит он только для проверенного текста или для HTML, который прошёл через санитайзер. Атрибуты вроде href автоэкранирование не проверяет по смыслу, поэтому ссылки из пользовательского ввода всё равно нужно фильтровать. Больше о защите страницы от чужого кода — в статье про сторонние скрипты.

Фигурные скобки: окно в JavaScript

Внутри JSX фигурные скобки переключают из разметки обратно в JavaScript. В них можно положить переменную, вызов функции, арифметику — любое выражение:

const price = 120;
const qty = 3;

const total = (
  <p>
    Итого: {price * qty} EUR, доставка {new Date().toLocaleDateString('uk-UA')}
  </p>
);

Ключевое слово — выражение, то есть код, который возвращает значение. Инструкции вроде if, for или объявления переменной внутрь скобок не поместятся: после компиляции содержимое скобок становится значением в объекте пропсов, а if значением быть не может. Поэтому в JSX свои привычные приёмы.

Условие — тернарный оператор или &&:

function OrderStatus({ isPaid, trackingCode }) {
  return (
    <div>
      {isPaid ? <p>Оплачен</p> : <p>Ждёт оплаты</p>}
      {trackingCode && <p>Трек-номер: {trackingCode}</p>}
    </div>
  );
}

Если условие длинное, его удобнее вынести из разметки: посчитать результат обычным if над return и подставить переменную. Разбор пропсов прямо в параметрах функции, как в OrderStatus, — обычная деструктуризация, подробно она разобрана в статье о деструктуризации в JavaScript.

Список — метод map, который превращает массив данных в массив элементов. Каждому элементу нужен проп key с уникальным и стабильным значением, чтобы React понимал, какой элемент куда переехал при обновлении:

const cities = [
  { id: 1, name: 'Kyiv' },
  { id: 2, name: 'Warsaw' },
  { id: 3, name: 'Berlin' },
];

const list = (
  <ul>
    {cities.map((city) => (
      <li key={city.id}>{city.name}</li>
    ))}
  </ul>
);

Индекс массива в роли key подходит только для списков, которые никогда не сортируются и не меняются в середине. Иначе при удалении или перестановке строк React перепутает их состояние: введённый текст останется в поле, которое теперь относится к другой записи.

Ловушка с нулём

Приём с && коварен, если слева число. Оператор возвращает левую часть, когда она ложная, а React честно выводит число 0 на страницу. Не выводятся только false, true, null и undefined.

// При пустой корзине на странице появится одинокий «0»
{cartCount && <span className="badge">{cartCount}</span>}

// Так правильно: слева явное булевое значение
{cartCount > 0 && <span className="badge">{cartCount}</span>}

Та же история с NaN и пустой строкой: первое выведется текстом, второе — пустым местом. Надёжное правило — слева от && всегда ставить сравнение или Boolean(...).

Комментарии

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

<nav>
  {/* Пункт временно скрыт до релиза */}
  <a href="/catalog">Каталог</a>
</nav>

Чем JSX отличается от HTML

Атрибуты в JSX после компиляции становятся ключами JavaScript-объекта, а сам JSX подчиняется строгим правилам XML. Отсюда почти все отличия от привычной вёрстки:

В HTML В JSX Почему
<br>, <img src="a.png"> <br />, <img src="a.png" /> Каждый тег обязан быть закрыт, пустые — через />. Самозакрываться может любой тег, хоть <div />
class="card" className="card" class — зарезервированное слово JavaScript, имя взято из DOM-свойства element.className
<label for="email"> <label htmlFor="email"> for тоже занято циклом
tabindex, maxlength, stroke-width tabIndex, maxLength, strokeWidth Имена атрибутов пишутся в camelCase, как ключи объекта
aria-label, data-id aria-label, data-id Исключение из camelCase: эти атрибуты исторически пишутся через дефис
onclick="save()" onClick={save} Обработчик — ссылка на функцию, а не строка с кодом
style="font-size: 14px" style={{ fontSize: 14 }} Стили передаются объектом, подробнее ниже
Несколько соседних тегов на верхнем уровне Один корень или фрагмент <>...</> Компонент возвращает одно значение, а не несколько объектов подряд
<!-- комментарий --> {/* комментарий */} Внутри разметки работают только JavaScript-комментарии

Стили — это объект

Атрибут style принимает не строку, а объект, ключи которого — CSS-свойства в camelCase. Отсюда двойные фигурные скобки, которые пугают новичков: внешние открывают JavaScript-выражение, внутренние — литерал объекта.

const bannerStyle = {
  backgroundColor: '#14336f',
  color: 'white',
  padding: 16,        // число без единиц = пиксели
  lineHeight: 1.4,    // у безразмерных свойств px не добавляется
};

const banner = <div style={bannerStyle}>Скидка до пятницы</div>;

// То же самое прямо в теге
const note = <p style={{ fontSize: 14, marginTop: 8 }}>Только онлайн</p>;

Числовые значения React дополняет единицей px сам, кроме свойств, которые по смыслу безразмерные: lineHeight, opacity, zIndex, flexGrow. CSS-переменные передаются как есть, с дефисами в кавычках: style={{ '--accent': '#859900' }}. Основная работа со стилями всё равно идёт через className и CSS-файлы, а объект нужен для того, что считается на лету: ширины полосы прогресса, координат, цвета из данных.

Фрагменты вместо лишних обёрток

Требование одного корня объясняется устройством JSX: компонент возвращает результат одного вызова _jsx, а функция в JavaScript не может вернуть два значения подряд. Обернуть всё в <div> можно, но лишний блок ломает вёрстку там, где важна структура: внутри таблицы, списка или grid-контейнера. Для таких случаев есть фрагмент — обёртка, которая не попадает в DOM:

function PriceRow({ title, price }) {
  return (
    <>
      <td>{title}</td>
      <td>{price} EUR</td>
    </>
  );
}

Короткой записи <> нельзя передать атрибуты. Если фрагменты выводятся списком и им нужен key, пишется полная форма <Fragment key={item.id}> с импортом Fragment из react. С React 19.3 полной форме можно передать и ref: через него получится, например, повесить обработчик события или поставить фокус сразу на группу дочерних элементов без обёртки.

Компоненты пишутся с заглавной буквы

По первой букве тега компилятор решает, что перед ним. <button> со строчной превращается в строку 'button', то есть в обычный HTML-тег. <Button> с заглавной — в ссылку на переменную Button, то есть в компонент:

function badge({ text }) {
  return <span className="badge">{text}</span>;
}

// Компилируется в _jsx('badge', ...) — React создаст несуществующий тег <badge>
const wrong = <badge text="Новинка" />;

function Badge({ text }) {
  return <span className="badge">{text}</span>;
}

// Компилируется в _jsx(Badge, ...) — вызовется функция компонента
const right = <Badge text="Новинка" />;

Ошибки компиляции в первом случае не будет: React молча создаст в DOM неизвестный элемент <badge> и выведет в консоль предупреждение. Поэтому имя компонента всегда начинается с заглавной буквы. Кстати, в React 19 функциональный компонент получает ref как обычный проп рядом с остальными, без обёртки forwardRef, которая раньше была для этого обязательна.

Частые ошибки при переносе вёрстки в JSX

Когда готовый HTML-макет переносят в компонент, ошибки обычно одни и те же:

  • Незакрытые пустые теги. <input>, <img>, <br>, <hr> без слэша ломают сборку.
  • Оставленный class. Сейчас React его чаще всего переварит, но выведет предупреждение. Привычка писать className избавляет от шума в консоли.
  • Строка в style. style="color: red" приводит к ошибке: React ждёт объект.
  • Вызов вместо ссылки в обработчике. onClick={save()} вызовет функцию при каждой отрисовке, а не по клику. Нужно onClick={save} или onClick={() => save(id)}, если нужен аргумент.
  • Текст в <textarea> и <select>. Начальное значение задаётся пропом defaultValue (или value), а не содержимым тега или атрибутом selected у опции.
  • Фигурные скобки в тексте. Символы { и } в обычном тексте компилятор примет за начало выражения. Чтобы вывести их буквально, их оборачивают в строку: {'{'}.

Крупные куски вёрстки и SVG-иконки руками переписывать не обязательно: конвертер HTML to JSX переименует атрибуты, закроет теги и превратит строки стилей в объекты. Результат всё равно стоит просмотреть глазами: обработчики событий конвертер не перенесёт.

Отдельно про пропсы: если нужно передать в элемент сразу все поля объекта, в JSX работает spread-синтаксис — <input {...inputProps} />. Как устроено само троеточие, рассказано в статье об операторах rest и spread.

Итог

  • JSX — не HTML и не отдельный язык, а сокращённая запись вызовов функций, которые возвращают объекты-описания интерфейса.
  • Браузер JSX не понимает, его компилирует Babel, TypeScript или Oxc в Vite. Современная схема сама импортирует функции из react/jsx-runtime, поэтому import React ради JSX не нужен.
  • Без JSX React работает, но вложенность в вызовах createElement быстро становится нечитаемой.
  • В фигурных скобках — только выражения: тернарный оператор, && с явным булевым значением слева, map со стабильным key.
  • От HTML JSX отличается в деталях, которые следуют из его устройства: закрытые теги, className и htmlFor, camelCase, стили объектом, один корень или фрагмент, компоненты с заглавной буквы.