Props are how a component takes arguments. You pass them like HTML attributes, and the component receives them as one object.
// Passing
<ProductCard product={product} onSelect={handleSelect} />
// Receiving
function ProductCard(props) {
return <div>{props.product.name}</div>;
}In practice nobody writes props. everywhere. Destructure in the signature:
function ProductCard({ product, onSelect }) {
return <div>{product.name}</div>;
}Typing them
Props are the one place TypeScript consistently earns its keep in a React codebase — it is the contract between two files. Two styles, both common:
// Inline, for one or two props.
export function AppNavbar({ onOpenCart }: { onOpenCart: () => void }) { /* … */ }
// A named interface, once there are more or they are reused.
interface Props {
product: Product;
onSelect: (product: Product) => void;
}
export function ProductCard({ product, onSelect }: Props) { /* … */ }Note the type of onSelect: (product: Product) => void. A function
prop is typed like any other function. Get the arity or the argument type wrong at the call site and
the build fails rather than the click doing nothing at runtime.
Optional props get a ?, and usually a default:
export function ProtectedRoute({
children,
requireAdmin = false,
}: {
children: ReactNode;
requireAdmin?: boolean;
}) { /* … */ }The default goes in the destructuring, not in the type. requireAdmin?: boolean says
the caller may omit it; requireAdmin = false says what happens when they do. Both are
needed.
Props are read-only
This is the rule that explains a large share of React confusion.
function ProductCard({ product }: Props) {
product.name = 'Something else'; // WRONG. Do not do this.
// …
}A component must not modify its props. It is not that React forbids it loudly — in plain JavaScript that assignment succeeds — it is that the parent owns that data and has no idea you changed it. The parent will not re-render, other components reading the same object will disagree about its contents, and you will spend an afternoon on it.
If a component needs to change something, that something belongs in state, owned by whoever is highest in the tree that cares. See State with useState.
Talking back to the parent: function props
Data flows down. When a child needs to cause something upward, the parent passes down a function and the child calls it.
The menu page owns which product is being configured. The card does not — it only knows one was clicked:
// MenuPage.tsx — the parent owns the state and hands down a way to change it.
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const handleSelect = useCallback((product: Product) => {
setSelectedProduct(product);
}, []);
return (
<Row xs={1} sm={2} lg={4} className="g-4">
{visibleProducts.map((product) => (
<Col key={product.id}>
<ProductCard product={product} onSelect={handleSelect} />
</Col>
))}
</Row>
);// ProductCard.tsx — the child reports the event and knows nothing about modals.
<Button size="sm" variant="primary" onClick={() => onSelect(product)}>
{isPizza ? 'Build it' : 'Add'}
</Button>This is the pattern everywhere: onSelect, onOpenCart,
onHide. The on… naming is convention, not syntax, but follow it — it is how
a reader knows a prop is an event rather than data.
The child stays reusable precisely because it does not know what happens next. Drop
ProductCard on the home page and pass a different onSelect, and it works.
The children prop
Anything you put between a component's tags arrives as a prop named children:
<ProtectedRoute requireAdmin>
<AdminLayout />
</ProtectedRoute><AdminLayout /> is props.children. The wrapper decides whether and
where to render it:
export function ProtectedRoute({ children, requireAdmin = false }: Props) {
const { isAuthenticated, isAdmin, initialising } = useAuth();
const location = useLocation();
if (initialising) return <Spinner />;
if (!isAuthenticated) return <Navigate to="/login" state={{ from: location }} replace />;
if (requireAdmin && !isAdmin) return <Navigate to="/" replace />;
return <>{children}</>;
}That is a component whose whole job is to decide whether to render its children. Error boundaries, providers, layouts and modals all work this way — it is the main tool React gives you for wrapping behaviour around arbitrary content.
Type it as ReactNode, which covers elements, strings, numbers, arrays and
null:
import type { ReactNode } from 'react';
interface Props {
children: ReactNode;
}children does not have to be markup. It can be a function, an object, anything —
props are just an object and children is just a key. In practice it is markup
essentially always.
What should be a prop?
Pass what the child needs, at the level of abstraction it works at.
ProductCard takes the whole product rather than
name, description, sizes and type as four props —
they always travel together and the card is about a product. But AppNavbar
takes only onOpenCart, not the cart itself, because it reads the cart from context.
That last part matters. If you find yourself passing a prop through three components that do not use it, just to reach the fourth, you have hit prop drilling — and the answer is Context, not more props.
Next
Conditional Rendering — showing something only sometimes.