Компонент React — это функция JavaScript с именем с заглавной буквы, которая возвращает разметку JSX. Данные в компонент передаются через props — объект с параметрами, который компонент читает, но не меняет. Списки рисуют через map с уникальным key у каждого элемента, а условный контент — обычными if, ? : и &&.
После материала вы сможете
- создать проект 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 г.
Содержание
Зачем React: интерфейс из компонентов
В уроке о DOM мы находили элементы на странице и меняли их вручную: добавить карточку, спрятать блок, обновить текст. Чем больше интерфейс, тем легче забыть одно из таких изменений. React предлагает другой подход: вы описываете, как интерфейс должен выглядеть при текущих данных, а библиотека сама приводит страницу к этому виду.
Интерфейс при этом собирается из компонентов. По определению react.dev, компонент — это функция JavaScript, в которую можно добавить разметку. Кнопка, карточка услуги, раздел каталога, вся страница — всё это компоненты, и одни компоненты рисуют другие.
В этом уроке соберём каталог услуг салона красоты: разделы по категориям, в каждом — карточки из массива данных. На карточке — название, длительность, цена или пометка «после консультации», список того, что входит в услугу, и значок «Новинка». Для категории без услуг — понятная заглушка. Всё на TypeScript: типы из прошлого урока опишут, какие данные принимает каждый компонент.
Создание проекта на Vite
Vite — инструмент сборки: запускает сервер разработки с мгновенным обновлением страницы и собирает проект для публикации. Документация Vite требует Node.js 20.19+ или 22.12+; мы работаем на Node.js 24 LTS. Создадим проект из официального шаблона react-ts — React и TypeScript уже настроены.
npm create vite@latest services-catalog -- --template react-ts
cd services-catalog
npm install
npm run devШаблон создаёт index.html, папку src и конфигурацию TypeScript. Точка входа — src/main.tsx: она находит в HTML элемент #root и рисует в нём корневой компонент App. Файлы с JSX по документации React должны иметь расширение .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>,
)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: тип услуги, список категорий и массив услуг. Позже данные будут приходить с сервера, но компонентам это неважно — они получают готовые объекты.
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.
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>
Если разметка начинается не на той же строке, что 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.
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} />
</>
)
}$ 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 разрешает любое отрисовываемое содержимое: элементы, строки, числа, списки.
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 два уровня списков: разделы по категориям, а в каждом — карточки услуг этой категории.
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 Appimport 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 у карточек:
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.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. Тест ловит эту ошибку: заменим условие на
{features.length && ($ 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)Когда условий становится много, документация советует не громоздить тернарные операторы в разметке, а вычислить содержимое заранее: объявить переменную через let, присвоить ей JSX в if и вставить {content}. Возвращать null из компонента тоже можно, но react.dev отмечает, что это встречается нечасто: обычно условие ставят в родителе.
Проверка: сборка, тесты и практика
Убедимся, что каталог работает, не только глазами. Поставим Vitest — тест-раннер, который использует конфигурацию Vite, — jsdom для DOM в Node.js и React Testing Library для отрисовки компонентов в тестах.
npm install -D vitest jsdom @testing-library/react @testing-library/dom/// <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'],
},
})import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// без globals: true Testing Library не очищает DOM сама — делаем это после каждого теста
afterEach(() => {
cleanup()
})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 запускает тесты один раз.
$ 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.