Как делать11 октября 2026 г.7 мин чтения
ReactШаг 2 из 8

React: компоненты, JSX и props

Коротко

Компонент React — это функция JavaScript с именем с заглавной буквы, которая возвращает разметку JSX. Данные в компонент передаются через props — объект с параметрами, который компонент читает, но не меняет. Списки рисуют через map с уникальным key у каждого элемента, а условный контент — обычными if, ? : и &&.

Пошаговый урокУровень: НачальныйReactПрактика: ~45–90 мин

После материала вы сможете

  • создать проект React + TypeScript на Vite и объяснить, что делает каждый файл шаблона
  • написать функциональный компонент с типизированными props, значением по умолчанию и children
  • отрисовать список через map со стабильным key и объяснить, почему индекс — плохой ключ
  • показать контент по условию через if, ? : и && без ловушки с 0 и проверить это тестом

Проверено на

React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3Node.js 24 LTS (24.18.0)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Зачем React: интерфейс из компонентов
  2. 02Создание проекта на Vite
  3. 03Компонент и JSX: правила разметки
  4. 04Props и children
  5. 05Списки и key
  6. 06Условный рендеринг
  7. 07Проверка: сборка, тесты и практика

Зачем React: интерфейс из компонентов

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

Интерфейс при этом собирается из компонентов. По определению react.dev, компонент — это функция JavaScript, в которую можно добавить разметку. Кнопка, карточка услуги, раздел каталога, вся страница — всё это компоненты, и одни компоненты рисуют другие.

В этом уроке соберём каталог услуг салона красоты: разделы по категориям, в каждом — карточки из массива данных. На карточке — название, длительность, цена или пометка «после консультации», список того, что входит в услугу, и значок «Новинка». Для категории без услуг — понятная заглушка. Всё на TypeScript: типы из прошлого урока опишут, какие данные принимает каждый компонент.

Создание проекта на Vite

Vite — инструмент сборки: запускает сервер разработки с мгновенным обновлением страницы и собирает проект для публикации. Документация Vite требует Node.js 20.19+ или 22.12+; мы работаем на Node.js 24 LTS. Создадим проект из официального шаблона react-ts — React и TypeScript уже настроены.

bash
npm create vite@latest services-catalog -- --template react-ts
cd services-catalog
npm install
npm run dev
Сервер разработки откроется на http://localhost:5173. Флаг --no-interactive позволяет пропустить вопросы мастера.

Шаблон создаёт index.html, папку src и конфигурацию TypeScript. Точка входа — src/main.tsx: она находит в HTML элемент #root и рисует в нём корневой компонент App. Файлы с JSX по документации React должны иметь расширение .tsx.

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
Файл из шаблона без изменений. StrictMode включает дополнительные проверки в режиме разработки — подробно о нём в уроке про useEffect.

index.html

Зачем
страница с пустым <div id="root"> и подключением src/main.tsx

src/main.tsx

Зачем
точка входа: создаёт корень React и рисует App

src/App.tsx

Зачем
корневой компонент — его заменим каталогом

tsconfig.app.json

Зачем
настройки TypeScript для кода приложения

vite.config.ts

Зачем
конфигурация Vite и плагина React

Демо-содержимое шаблона нам не понадобится: удалите src/assets и src/App.css, а src/index.css замените своими стилями — они в уроке не важны. Шаблон на момент проверки ставит React 19.3, Vite 8.3 и TypeScript 6.0; проект также проходит проверку компилятором TypeScript 7.0.

Компонент и JSX: правила разметки

Начнём с данных. Это обычный модуль TypeScript: тип услуги, список категорий и массив услуг. Позже данные будут приходить с сервера, но компонентам это неважно — они получают готовые объекты.

src/data/services.ts
export type Category = 'hair' | 'nails' | 'brows'

export interface Service {
  id: string
  title: string
  category: Category
  durationMin: number
  priceFrom?: number // нет цены — «после консультации»
  features: string[]
  isNew?: boolean
}

export const categories: { id: Category; title: string }[] = [
  { id: 'hair', title: 'Волосы' },
  { id: 'nails', title: 'Ногти' },
  { id: 'brows', title: 'Брови' },
]

export const services: Service[] = [
  {
    id: 'haircut',
    title: 'Женская стрижка',
    category: 'hair',
    durationMin: 60,
    priceFrom: 9000,
    features: ['Консультация мастера', 'Мытьё и укладка'],
  },
  {
    id: 'coloring',
    title: 'Окрашивание',
    category: 'hair',
    durationMin: 180,
    features: ['Подбор оттенка', 'Уход после окрашивания'],
    isNew: true,
  },
  {
    id: 'manicure',
    title: 'Маникюр с покрытием',
    category: 'nails',
    durationMin: 90,
    priceFrom: 7000,
    features: [],
  },
]

Теперь карточка. Компонент — функция, которая возвращает JSX: разметку, похожую на HTML, внутри JavaScript.

src/components/ServiceCard.tsx
import type { Service } from '../data/services.ts'

interface ServiceCardProps {
  service: Service
  currency?: string
}

export function ServiceCard({ service, currency = '₸' }: ServiceCardProps) {
  const { title, durationMin, priceFrom, features, isNew } = service

  return (
    <article className="card">
      <h3>
        {title}
        {isNew && <span className="badge">Новинка</span>}
      </h3>
      <p className="meta">
        {durationMin} мин ·{' '}
        {priceFrom !== undefined
          ? `от ${priceFrom} ${currency}`
          : 'цена после консультации'}
      </p>
      {features.length > 0 && (
        <ul>
          {features.map((feature) => (
            <li key={feature}>{feature}</li>
          ))}
        </ul>
      )}
    </article>
  )
}

Один корневой элемент

Как в карточке
всё обёрнуто в <article>
Почему так
JSX превращается в объекты JavaScript, а функция возвращает одно значение. Если обёртка не нужна, есть фрагмент <>…</>

Все теги закрыты

Как в карточке
<li>…</li>; одиночные — <img />
Почему так
так требует синтаксис JSX

camelCase в атрибутах

Как в карточке
className вместо class
Почему так
атрибуты становятся ключами объекта, а class — зарезервированное слово. Исключение — aria-* и data-*

Выражения в фигурных скобках

Как в карточке
{title}, {durationMin} мин
Почему так
внутри {} — любое выражение JavaScript: переменная, вызов, тернарный оператор

Имя с заглавной буквы

Как в карточке
ServiceCard, не serviceCard
Почему так
по имени React отличает свой компонент <ServiceCard /> от HTML-тега <section>
Правила из react.dev: Your First Component и Writing Markup with JSX.

Если разметка начинается не на той же строке, что return, оберните её в круглые скобки, как в примере. Без скобок JavaScript закончит return на переводе строки, и компонент ничего не вернёт.

Props и children

Props — данные, которые родитель передаёт компоненту через атрибуты JSX: <ServiceCard service={service} />. Компонент получает их одним объектом — первым параметром функции. Обычно объект сразу деструктурируют: ({ service, currency = '₸' }). Описание props — обычный interface.

  • service: Service — обязательный prop: без него TypeScript не даст отрисовать карточку.
  • currency?: string с значением по умолчанию '₸'. По react.dev, значение по умолчанию используется, только если prop не передан или равен undefined; null и 0 считаются переданными значениями.
  • const { title, … } = service — деструктуризация уже внутри функции: дальше в разметке короткие имена.

Проверим, что даёт типизация. Положим в src учебный файл с тремя ошибками и запустим проверку типов, которую выполняет npm run build.

src/Mistakes.tsx
import { ServiceCard } from './components/ServiceCard.tsx'
import { services } from './data/services.ts'

export function Mistakes() {
  return (
    <>
      <ServiceCard />
      <ServiceCard service={services[0]} curency="KZT" />
      <ServiceCard service={services[0]} currency={398} />
    </>
  )
}
Учебный файл с намеренными ошибками — после проверки удалите.
terminal
$ npx tsc -b
src/Mistakes.tsx(7,8): error TS2741: Property 'service' is missing in type '{}' but required in type 'ServiceCardProps'.
src/Mistakes.tsx(8,42): error TS2322: Type '{ service: Service; curency: string; }' is not assignable to type 'IntrinsicAttributes & ServiceCardProps'.
  Property 'curency' does not exist on type 'IntrinsicAttributes & ServiceCardProps'. Did you mean 'currency'?
src/Mistakes.tsx(9,42): error TS2322: Type 'number' is not assignable to type 'string'.

Забытый обязательный prop, опечатка в имени (TypeScript даже подсказывает правильное) и значение не того типа найдены до запуска. В JavaScript-проекте все три ошибки проявились бы только в браузере — или не проявились бы вовсе, а карточка молча показала бы undefined.

Особый prop — children. По документации, если вложить содержимое внутрь тега компонента, родитель получит его в prop children. Компонент с children — как рамка с «дыркой», которую заполняет родитель. Тип ReactNode разрешает любое отрисовываемое содержимое: элементы, строки, числа, списки.

src/components/Section.tsx
import type { ReactNode } from 'react'

interface SectionProps {
  title: string
  children: ReactNode
}

export function Section({ title, children }: SectionProps) {
  return (
    <section className="section">
      <h2>{title}</h2>
      {children}
    </section>
  )
}

Списки и key

Списки в React рисуют обычными методами массивов: filter отбирает нужные элементы, map превращает каждый в JSX. В App два уровня списков: разделы по категориям, а в каждом — карточки услуг этой категории.

src/App.tsx
import { Section } from './components/Section.tsx'
import { ServiceList } from './components/ServiceList.tsx'
import { categories, services } from './data/services.ts'

function App() {
  return (
    <main>
      <h1>Услуги салона</h1>
      {categories.map((category) => (
        <Section key={category.id} title={category.title}>
          <ServiceList
            items={services.filter((service) => service.category === category.id)}
          />
        </Section>
      ))}
    </main>
  )
}

export default App
src/components/ServiceList.tsx
import type { Service } from '../data/services.ts'
import { ServiceCard } from './ServiceCard.tsx'

export function ServiceList({ items }: { items: Service[] }) {
  if (items.length === 0) {
    return <p className="empty">В этой категории пока нет услуг.</p>
  }

  return (
    <div className="grid">
      {items.map((service) => (
        <ServiceCard key={service.id} service={service} />
      ))}
    </div>
  )
}

Каждому элементу, который создаёт map, нужен key — строка или число, уникальные среди соседей. По react.dev, ключ говорит React, какому элементу массива соответствует какой компонент, чтобы при вставке, удалении или перестановке правильно обновить страницу. Вот что выводится в консоль, если убрать key у карточек:

terminal
stderr | src/App.test.tsx > каталог услуг > рисует по карточке на каждую услугу из массива
Each child in a list should have a unique "key" prop.

Check the render method of `ServiceList`. See https://react.dev/link/warning-keys for more information.
Предупреждение React в режиме разработки — в консоли браузера или в выводе тестов.

id из базы данных или API

Подходит?
да
Почему
уникален и не меняется

Значение, уникальное в списке

Подходит?
да, с оговоркой
Почему
как feature в карточке: работает, пока строки не повторяются

crypto.randomUUID() при создании записи

Подходит?
да
Почему
для данных, созданных в браузере, — один раз при создании, не при отрисовке

Индекс в массиве

Подходит?
только для неизменного списка
Почему
при вставке, удалении и сортировке индекс переезжает к другому элементу — по документации это ведёт к трудноуловимым ошибкам

Math.random() в разметке

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

Ключ нужен React, а не компоненту: ServiceCard не получит key в props. Если компоненту нужен идентификатор, передайте его отдельным prop. И ещё одна деталь из документации: стрелочная функция в map с фигурными скобками требует явного return, иначе список окажется пустым.

Условный рендеринг

Условия в JSX — это обычный JavaScript. В каталоге использованы три приёма, каждый на своём месте.

if и ранний return

Где в каталоге
ServiceList: пустая категория → заглушка
Когда применять
когда компонент целиком выглядит по-разному

Тернарный оператор ? :

Где в каталоге
цена: от 9000 ₸ или «цена после консультации»
Когда применять
выбор из двух вариантов внутри разметки

Логическое &&

Где в каталоге
значок «Новинка», список включённого
Когда применять
показать или ничего не показывать

Для && документация React делает важное предупреждение: не ставьте число слева. Если features пустой, выражение features.length && … равно 0, и React нарисует этот ноль на карточке — false, null и undefined он пропускает, а числа выводит. Поэтому в карточке условие записано как features.length > 0. Тест ловит эту ошибку: заменим условие на

src/components/ServiceCard.tsx
{features.length && (
Фрагмент с ошибкой — только для эксперимента.
terminal
$ npx vitest run
 FAIL  src/App.test.tsx > каталог услуг > выводит значок, цену и список по условию
AssertionError: expected …(3) to have a length of 2 but got 3

      Tests  1 failed | 2 passed (3)
Вывод сокращён. Третий узел внутри карточки — тот самый текст «0».

Когда условий становится много, документация советует не громоздить тернарные операторы в разметке, а вычислить содержимое заранее: объявить переменную через let, присвоить ей JSX в if и вставить {content}. Возвращать null из компонента тоже можно, но react.dev отмечает, что это встречается нечасто: обычно условие ставят в родителе.

Проверка: сборка, тесты и практика

Убедимся, что каталог работает, не только глазами. Поставим Vitest — тест-раннер, который использует конфигурацию Vite, — jsdom для DOM в Node.js и React Testing Library для отрисовки компонентов в тестах.

bash
npm install -D vitest jsdom @testing-library/react @testing-library/dom
vite.config.ts
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
})
src/test/setup.ts
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

// без globals: true Testing Library не очищает DOM сама — делаем это после каждого теста
afterEach(() => {
  cleanup()
})
src/App.test.tsx
import { render, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import App from './App.tsx'
import { ServiceCard } from './components/ServiceCard.tsx'

describe('каталог услуг', () => {
  it('рисует по карточке на каждую услугу из массива', () => {
    render(<App />)
    expect(screen.getAllByRole('article')).toHaveLength(3)
  })

  it('показывает заглушку для категории без услуг', () => {
    render(<App />)
    expect(screen.getByText('В этой категории пока нет услуг.')).toBeTruthy()
  })

  it('выводит значок, цену и список по условию', () => {
    render(
      <ServiceCard
        service={{
          id: 'test',
          title: 'Тестовая услуга',
          category: 'nails',
          durationMin: 30,
          features: [],
          isNew: true,
        }}
      />,
    )
    const card = screen.getByRole('article')
    expect(within(card).getByText('Новинка')).toBeTruthy()
    expect(within(card).getByText('30 мин · цена после консультации')).toBeTruthy()
    expect(within(card).queryByRole('list')).toBeNull()
    expect(card.childNodes).toHaveLength(2) // h3 и p — без лишнего «0»
  })
})

В package.json добавьте скрипт "test": "vitest run". npm run build сначала проверяет типы (tsc -b), затем собирает проект; npm test запускает тесты один раз.

terminal
$ npm run build

> services-catalog@0.0.0 build
> tsc -b && vite build

vite v8.3.4 building client environment for production...
transforming...
✓ 20 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html                   0.46 kB │ gzip:  0.29 kB
dist/assets/index-DLgvb7S-.css    0.41 kB │ gzip:  0.27 kB
dist/assets/index-CT4aR44L.js   221.12 kB │ gzip: 69.42 kB

✓ built in 126ms

$ npm test

> services-catalog@0.0.0 test
> vitest run

 RUN  v5.0.3 …/services-catalog

 Test Files  1 passed (1)
      Tests  3 passed (3)
Хеши файлов и время сборки у вас будут другими.

Каталог пока статичный: что передали в props, то и нарисовано. Следующий шаг траектории — состояние и useState: добавим фильтр по категории, корзину и поля ввода, и интерфейс начнёт отвечать на действия пользователя.

Как проверено6 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    Шаблон: npm create vite@9.2.1 (latest) services-catalog --template react-ts --no-interactive; src/main.tsx

    проект создан во временной папке, npm install: react/react-dom 19.3.0, vite 8.3.4, typescript 6.0.3 (~6.0.2 из шаблона); dev-сервер vite отвечает 200

  • запущено, работает

    src/data/services.ts, ServiceCard.tsx, Section.tsx, ServiceList.tsx, App.tsx, vite.config.ts, src/test/setup.ts, src/App.test.tsx

    npm run build (tsc -b + vite build 8.3.4) — успешно; дополнительно tsc -b на TypeScript 7.0.2 — 0 ошибок; npm test (vitest 5.0.3 + jsdom 30.1.2 + @testing-library/react 16.3.3) — 3/3

  • запущено, работает

    src/Mistakes.tsx (намеренные ошибки props)

    tsc -b — три ошибки TS2741/TS2322/TS2322, вывод скопирован

  • запущено, работает

    Предупреждение о key; ловушка 0 в &&

    key удалён временно — stderr vitest содержит предупреждение (скопировано); условие заменено на features.length && — тест падает (1 failed), после возврата 3/3

  • запущено, работает

    Решения упражнений (brows, Badge + isHit, PriceList)

    копия проекта ex/: tsc -b, vite build, vitest 6/6 (обновлённые тесты + тесты PriceList); фрагменты сверены программно

  • запущено, работает

    EN-версия кода

    копии en/ и en-ex/ с переведёнными строками: tsc -b, vite build, vitest 3/3 и 6/6

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.