Карточке товара нужны название и цена, а описание и фотография иногда отсутствуют. Опишите данные через пропсы, задайте поведение для пропусков и передайте действие «Добавить в корзину» родителю. TypeScript проверит вызовы компонента. Ещё до запуска страницы.
Пропсы (props) — параметры, которые родитель передаёт компоненту. Наш ProductCard покажет вымышленную кружку за 1299 ₽ и сообщит родителю её строковый идентификатор. Настоящую корзину здесь не собираем: под карточкой появится результат нажатия. Так видно, правильно ли работает её контракт.
Пример рассчитан на существующий клиентский проект с React, TypeScript, обработкой JSX и пакетами типов @types/react и @types/react-dom. Подойдёт шаблон React + TypeScript в Vite. Файлы с JSX сохраняйте с расширением .tsx, а в настройках TypeScript включите strict. Основы языка есть в нашей статье о TypeScript.
Опишите данные и обязательное действие
Сохраните в src/ProductCard.tsx полный компонент. Значения товара в примере известны заранее; сведения, полученные из API, требуют отдельной проверки во время выполнения.
import type { MouseEvent } from 'react';
export type Product = {
id: string;
title: string;
priceCents: number;
description?: string;
imageUrl?: string;
};
export type ProductCardProps = {
product: Product;
onAdd: (productId: string) => void;
};
const rubles = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
});
export function ProductCard({ product, onAdd }: ProductCardProps) {
function handleClick(event: MouseEvent<HTMLButtonElement>) {
onAdd(event.currentTarget.value);
}
return (
<article>
<h2>{product.title}</h2>
{product.imageUrl ? (
<img src={product.imageUrl} alt="" width="240" height="160" />
) : (
<p>Фото пока нет</p>
)}
<p>{product.description ?? 'Описание ещё готовится'}</p>
<p>{rubles.format(product.priceCents / 100)}</p>
<button type="button" value={product.id} onClick={handleClick}>
<span>Добавить</span> в корзину
</button>
</article>
);
}
В Product мы описали данные, в ProductCardProps — вход компонента целиком. Если у товара нет фотографии, карточка покажет текстовую замену, а при попытке передать цену строкой TypeScript сообщит об ошибке в месте вызова. Свойство onAdd обязательно. Явный контракт удобнее кнопки, которая молчит, когда родитель забыл обработчик: вызов такой карточки должен вызвать ошибку при проверке типов.
priceCents в учебном примере хранит целое число копеек: 129900 означает 1299 ₽. Для отображения делим значение на 100 и форматируем как рубли. Цена известна заранее. Если же данные придут из API, при их подготовке нужно проверить, что число целое и неотрицательное: сам тип number допускает дроби, отрицательные значения и даже NaN.
Импорт MouseEvent помечен import type: в этом файле он нужен для проверки типов. Общий подход к типизации параметров компонента и событий показывает руководство React по TypeScript.
Подключите карточку к родителю
Теперь src/App.tsx. Когда карточка вызывает переданный onAdd, родитель должен записать идентификатор в состояние, чтобы читатель увидел результат под кнопкой. Для этого подходит setLastAddedId: функция принимает строку.
import { useState } from 'react';
import { ProductCard } from './ProductCard';
import type { Product } from './ProductCard';
const product: Product = {
id: 'cup-raccoon',
title: 'Кружка с енотом',
priceCents: 129900,
};
export default function App() {
const [lastAddedId, setLastAddedId] = useState('');
return (
<>
<ProductCard product={product} onAdd={setLastAddedId} />
<p aria-live="polite">
{lastAddedId ? `Добавлен товар: ${lastAddedId}` : 'Выберите товар'}
</p>
</>
);
}
Точка входа src/main.tsx подключает приложение к <div id="root"></div> в index.html:
import { createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root');
if (container === null) {
throw new Error('В index.html нужен элемент с id="root"');
}
createRoot(container).render(<App />);
На странице появятся название кружки, цена и надписи вместо отсутствующих данных. Нажмите «Добавить в корзину». Родитель выведет Добавлен товар: cup-raccoon. Кнопку можно активировать и с клавиатуры: сфокусируйте её клавишей Tab, затем нажмите Enter или пробел, чтобы проверить тот же результат без мыши.
Задайте вид карточки без описания и фото
Знак ? в description?: string разрешает не передавать свойство. При чтении в режиме strict нужно учитывать undefined. Оператор ?? подставит запасной текст, если описание отсутствует, и сохранит переданную пустую строку. Это различие видно на странице. Смысл необязательных свойств описан в документации TypeScript об объектах.
Нужно ли считать пустую строку отсутствующим описанием? Это отдельное решение для интерфейса. В нашем контракте пустая строка допустима. Если API присылает пробелы вместо текста, очистите данные перед отображением или явно добавьте такое правило в компонент.
Фотографию проверяем через условие: отсутствующий или пустой imageUrl даст надпись «Фото пока нет». Адрес может оказаться битым. Файл может не загрузиться и при указанном URL; если карточке нужно показывать запасную картинку при ошибке сети, обработку ошибки изображения придётся добавить отдельно.
У картинки стоит alt="": в нашем примере фотография кружки повторяет соседнее название и носит декоративный характер. Если фотография сообщает то, чего нет в тексте, например цвет конкретного варианта, задайте ей осмысленное описание. Фиксированные width и height резервируют место под изображение.
| Часть контракта | Какой случай она покрывает |
|---|---|
id: string |
Родитель получает строковый идентификатор товара. |
priceCents: number |
Цена строкой не пройдёт проверку типов; допустимый диапазон проверяется отдельно. |
description?: string |
Карточку можно вызвать без описания. |
imageUrl?: string |
Отсутствующую фотографию можно заменить текстом. |
onAdd: (productId: string) => void |
Родитель обязан передать обработчик с подходящим аргументом. |
Прочитайте событие с кнопки
В компоненте обработчик вынесен в отдельную функцию, поэтому тип аргумента задан явно: MouseEvent<HTMLButtonElement>. Это тип события React, а HTMLButtonElement уточняет элемент, на котором работает обработчик.
Перед объяснением предскажите, будут ли event.target и event.currentTarget одним элементом при нажатии на слово «Добавить». Объясните свой ответ по вложенному <span>, затем сверьтесь с разбором ниже.
Кнопка получает value={product.id}. Во время нажатия event.currentTarget указывает на неё, и её value имеет тип string. Но нажать можно на вложенное слово «Добавить»: тогда event.target будет указывать на <span>, у которого значения кнопки нет. Проверьте оба места нажатия. Различие между источником события и элементом обработчика показано в справке MDN о currentTarget.
Родитель получает идентификатор. Когда вы замените надпись или добавите внутрь кнопки декоративный значок, аргумент onAdd останется строкой, и код родителя продолжит работать с тем же контрактом.
Когда данные уже есть в пропсах и само событие не нужно, обработчик можно сократить. Вместо value и handleClick у кнопки используйте onClick={() => onAdd(product.id)}. В полном примере чтение через currentTarget.value оставлено, чтобы проверить тип события на работающей кнопке.
Проверьте ошибки до запуска страницы
Поменяйте один параметр в родителе и посмотрите сообщение TypeScript. Это намеренно неверные варианты; в рабочем примере их оставлять не нужно.
// Ошибка: обязательный onAdd отсутствует.
<ProductCard product={product} />;
// Ошибка: priceCents должен быть числом.
const wrongProduct: Product = {
id: 'cup', title: 'Кружка', priceCents: '129900',
};
// Ошибка: обработчик ожидает число, карточка передаёт строку.
<ProductCard product={product} onAdd={(id: number) => console.log(id)} />;
Если убрать свойство description и вызвать product.description.toUpperCase(), проверка с strictNullChecks также укажет на возможное отсутствие значения. Сначала задайте поведение для undefined, после этого обращайтесь к методам строки.
Для проекта с этими файлами запустите npx tsc --noEmit. В шаблоне с несколькими файлами конфигурации может потребоваться команда проверки, уже заданная в package.json, или npx tsc -p tsconfig.app.json --noEmit. Успешная сборка Vite сама по себе не подтверждает проверку типов: преобразование TypeScript в JavaScript и проверка типов разделены. Об этом говорится в документации Vite.
Расширьте контракт карточки
Добавьте необязательное поле badge?: string для подписи «Новинка». Подсказка: выводите элемент подписи только при наличии непустой строки. Проверьте карточку с подписью и без неё; пустое место под отсутствующий значок появляться не должно.
Самостоятельная вариация: добавьте available: boolean. Недоступный товар должен показывать сообщение «Нет в наличии» и отключать кнопку через disabled. Убедитесь, что обязательное поле передано родителем и нажатие на отключённую кнопку не вызывает onAdd.
Что вернётся в onAdd, если нажать на вложенный <span>? Где вы проверите, что цена из API — целое неотрицательное число, если TypeScript уже знает тип number?
Для дальнейшей практики с компонентами посмотрите программу курса HTML Academy «React. Разработка сложных клиентских приложений».