Карточке товара нужны название и цена, а описание и фотография иногда отсутствуют. Опишите данные через пропсы, задайте поведение для пропусков и передайте действие «Добавить в корзину» родителю. 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. Разработка сложных клиентских приложений».


«Доктайп» — журнал о фронтенде. Читайте, слушайте и учитесь с нами.

ТелеграмПодкастБесплатные учебники