Typing Components and Props in React with TypeScript
Typing Components and Props
~15 Min. Lesezeit Zuletzt aktualisiert am August 8, 2026
Now we'll migrate ProductCard – the most frequently reused component in the project, and a perfect example of how TypeScript prevents exactly the errors that arise when passing props across multiple files.
ProductCard.jsx to ProductCard.tsx: the props interface
import { memo, useState } from 'react';
interface ProductCardProps {
name: string;
price: number;
imageUrl: string;
onSelect: () => void;
onAddToCart: () => void;
}
function ProductCard({ name, price, imageUrl, onSelect, onAddToCart }: ProductCardProps) {
const [isFavorite, setIsFavorite] = useState(false);
function handleFavoriteClick(event: React.MouseEvent) {
event.stopPropagation();
setIsFavorite(!isFavorite);
}
function handleAddToCartClick(event: React.MouseEvent) {
event.stopPropagation();
onAddToCart();
}
return (
<div className="product-card" onClick={onSelect}>
<img src={imageUrl} alt={name} className="product-card__image" />
<div className="product-card__info">
<h3 className="product-card__name">{name}</h3>
<p className="product-card__price">${price.toFixed(2)}</p>
</div>
<button className="product-card__favorite" onClick={handleFavoriteClick}>
{isFavorite ? '♥' : '♡'}
</button>
<button className="product-card__add-to-cart" onClick={handleAddToCartClick}>
Add to Cart
</button>
</div>
);
}
export default memo(ProductCard);The details that matter
interface ProductCardPropsdescribes the FULL public interface of the component – anyone reading the file sees IMMEDIATELY (without reading the whole function body) which props are required and what type they are.onSelect: () => void– the arrow syntax inside an interface describes a FUNCTION SIGNATURE: "a function that takes no arguments and returns nothing" (void, notundefined– the difference is covered in the TypeScript language tutorial).event: React.MouseEvent– the type for a synthetic React mouse event; without this annotation, TypeScript would implicitly treateventasany, which disables ALL type checking on it.memo(ProductCard)at the very end works UNCHANGED – TypeScript's type inference automatically recognizes that the result is still a component with the same props.
Experiencing the benefit live: a deliberate typo
Open ProductListPage.jsx (not yet migrated) and, as an experiment, change onAddToCart={{...}} to onAddToCard={{...}} (swapped "r"/"d"). Since ProductListPage.jsx itself is still JavaScript, your editor won't report anything HERE yet – instead, try changing ONE <ProductCard ... /> call in a .tsx file to have that typo: your editor underlines onAddToCard in RED and shows "Property 'onAddToCard' does not exist on type 'ProductCardProps'" – BEFORE running anything, not only when click-testing in the browser.
Bonus: typing children (for later components)
For components with children (like our Modal from chapter 38), there's a built-in React type:
import { ReactNode } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}ReactNode is the broadest "anything React can render" type (strings, numbers, JSX elements, arrays thereof, null, ...) – the right type for children when you don't want to restrict what can be passed in.
Optional props with the question mark
Remember size = 48 as a default-value pattern from the "React Native Reference" series (the Avatar topic)? The TypeScript equivalent of "this prop is optional" is a ? in the interface:
interface AvatarProps {
name: string;
imageUrl?: string; // optional - can be omitted
size?: number; // optional, with a default value in the function header
}
function Avatar({ name, imageUrl, size = 48 }: AvatarProps) {
// ...
}Tipp: Rule of thumb for migration order in a real project: "inside out", or "leaves to roots" – small, reusable components WITHOUT many dependencies (like ProductCard) first, complex page components with lots of imports (ProductListPage) last. That keeps the rest of the app runnable throughout the gradual migration.