
ReactJS
Props are React's fundamental mechanism for component composition and data flow. At an advanced level, understanding props architecture requires comprehending not just data passing, but unidirectional data flow patterns, performance optimization, TypeScript integration, and architectural patterns that scale.
React's unidirectional data flow ensures predictable state management. Data flows down from parent to child through props, and child components communicate upward through callbacks. This architecture prevents circular dependencies and makes debugging straightforward.
The fundamental principle: data sources are single and unified at the top level, flowing predictably downward. This contrasts with mutable objects passed between components, which would create unpredictable state mutations.
// Architectural pattern: Single source of truth
interface User {
id: string;
name: string;
email: string;
role: "admin" | "user";
}
interface UserProfileProps {
user: User;
isEditing: boolean;
onUpdate: (user: User) => Promise<void>;
onDeleteSuccess: () => void;
}
// Component establishes a contract through its props interface
function UserProfile({
user,
isEditing,
onUpdate,
onDeleteSuccess,
}: UserProfileProps) {
const [loading, setLoading] = useState(false);
const handleSave = async (updatedUser: User) => {
setLoading(true);
try {
await onUpdate(updatedUser);
} finally {
setLoading(false);
}
};
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
{isEditing && <EditForm user={user} onSave={handleSave} />}
</div>
);
}
// Parent controls the data flow entirely
function UserContainer() {
const [user, setUser] = useState<User | null>(null);
const [isEditing, setIsEditing] = useState(false);
const handleUpdate = async (updated: User) => {
const result = await api.updateUser(updated);
setUser(result);
};
return (
user && (
<UserProfile
user={user}
isEditing={isEditing}
onUpdate={handleUpdate}
onDeleteSuccess={() => setUser(null)}
/>
)
);
}<details>
<summary>📚 More Examples</summary>
// Example 2: Multi-level component hierarchy with data flowing through multiple levels
interface Post {
id: string;
title: string;
content: string;
author: User;
comments: Comment[];
}
interface PostProps {
post: Post;
canDelete: boolean;
onCommentAdd: (text: string) => Promise<void>;
onDelete: () => Promise<void>;
}
function PostComponent({ post, canDelete, onCommentAdd, onDelete }: PostProps) {
return (
<article>
<h1>{post.title}</h1>
<p>By {post.author.name}</p>
<p>{post.content}</p>
{post.comments.map((comment) => (
<Comment key={comment.id} comment={comment} />
))}
<CommentForm onSubmit={onCommentAdd} />
{canDelete && <button onClick={onDelete}>Delete Post</button>}
</article>
);
}
// Parent component manages all state and callbacks
function PostContainer({ postId }: { postId: string }) {
const [post, setPost] = useState<Post | null>(null);
const [user, setUser] = useState<User | null>(null);
const handleCommentAdd = async (text: string) => {
const updated = await api.addComment(postId, text);
setPost(updated);
};
const handleDelete = async () => {
await api.deletePost(postId);
// Navigate away
};
return (
post && (
<PostComponent
post={post}
canDelete={user?.id === post.author.id}
onCommentAdd={handleCommentAdd}
onDelete={handleDelete}
/>
)
);
}</details>
Well-designed props interfaces are contracts that make components reliable and maintainable. TypeScript enforces these contracts at compile time, catching bugs before they reach production.
Effective props design separates concerns: data props (what the component displays), callback props (what the component can do), and control props (component behavior). Each category serves a specific purpose and should be clearly named.
// Comprehensive props design pattern
interface Product {
id: string;
name: string;
price: number;
image: string;
}
// Separate data, callbacks, and control props
interface ProductCardProps {
// Data props: what to display
product: Product;
variant?: "compact" | "detailed" | "preview";
// Callback props: what actions the component can trigger
onAddToCart: (product: Product, quantity: number) => void;
onViewDetails: (productId: string) => void;
onFavorite?: (productId: string) => void;
// Control props: component behavior
showPrice?: boolean;
isDisabled?: boolean;
maxQuantity?: number;
}
function ProductCard({
product,
variant = "detailed",
onAddToCart,
onViewDetails,
onFavorite,
showPrice = true,
isDisabled = false,
maxQuantity = 10,
}: ProductCardProps) {
const [quantity, setQuantity] = useState(1);
const handleAddToCart = () => {
if (quantity > 0 && quantity <= maxQuantity && !isDisabled) {
onAddToCart(product, quantity);
}
};
const variants = {
compact: <div>{product.name}</div>,
detailed: (
<div>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
{showPrice && <p>${product.price}</p>}
</div>
),
preview: <img src={product.image} alt={product.name} />,
};
return (
<div style={{ opacity: isDisabled ? 0.5 : 1 }}>
{variants[variant]}
<input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
max={maxQuantity}
disabled={isDisabled}
/>
<button onClick={handleAddToCart} disabled={isDisabled}>
Add to Cart
</button>
{onFavorite && (
<button onClick={() => onFavorite(product.id)}>Favorite</button>
)}
<button onClick={() => onViewDetails(product.id)}>View</button>
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example 2: Discriminated union types for variant props
type FormFieldProps =
| {
type: "text";
validate?: (value: string) => boolean;
placeholder?: string;
}
| {
type: "number";
min?: number;
max?: number;
}
| {
type: "select";
options: { label: string; value: string }[];
};
interface BaseFieldProps {
name: string;
label: string;
value: string | number;
onChange: (value: string | number) => void;
error?: string;
required?: boolean;
}
type Field Props = BaseFieldProps & FormFieldProps;
// Component leverages TypeScript's discriminated unions for type safety
function FormField(props: FieldProps) {
const renderField = () => {
switch (props.type) {
case "text":
return (
<input
type="text"
placeholder={props.placeholder}
onChange={(e) => {
if (!props.validate || props.validate(e.target.value)) {
props.onChange(e.target.value);
}
}}
/>
);
case "number":
return (
<input
type="number"
min={props.min}
max={props.max}
onChange={(e) => props.onChange(Number(e.target.value))}
/>
);
case "select":
return (
<select onChange={(e) => props.onChange(e.target.value)}>
{props.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
};
return (
<div>
<label>
{props.label}
{props.required && <span>*</span>}
</label>
{renderField()}
{props.error && <span>{props.error}</span>}
</div>
);
}</details>
Performance issues often arise when parent components re-render unnecessarily, forcing all children to re-render even when their props haven't changed. React's `memo` HOC and proper dependency management prevent these unnecessary renders.
Understanding when and how to use memoization is crucial. Memoization should target expensive components (those with complex calculations or many descendants), not every single component.
import { memo, useMemo, useCallback } from "react";
// Without memoization: expensive calculations run on every parent render
function ProductList({ products }: { products: Product[] }) {
const [sortBy, setSortBy] = useState<"name" | "price">("name");
// This runs every render even if products unchanged
const sorted = products.sort((a, b) => {
if (sortBy === "name") return a.name.localeCompare(b.name);
return a.price - b.price;
});
// This function is recreated every render
const handleProductClick = (id: string) => {
console.log("Clicked:", id);
};
return (
<div>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
<option>name</option>
<option>price</option>
</select>
{sorted.map((product) => (
<ProductCard
key={product.id}
product={product}
onClick={handleProductClick}
/>
))}
</div>
);
}
// With memoization: optimized performance
interface MemoizedProductCardProps {
product: Product;
onClick: (id: string) => void;
}
const MemoizedProductCard = memo<MemoizedProductCardProps>(
({ product, onClick }) => (
<div onClick={() => onClick(product.id)}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
),
(prevProps, nextProps) => {
// Return true if props are equal (skip render)
return (
prevProps.product.id === nextProps.product.id &&
prevProps.onClick === nextProps.onClick
);
}
);
function OptimizedProductList({ products }: { products: Product[] }) {
const [sortBy, setSortBy] = useState<"name" | "price">("name");
// Memoized sorting: only recalculates when products or sortBy changes
const sorted = useMemo(() => {
return [...products].sort((a, b) => {
if (sortBy === "name") return a.name.localeCompare(b.name);
return a.price - b.price;
});
}, [products, sortBy]);
// Memoized callback: identity stable unless dependencies change
const handleProductClick = useCallback((id: string) => {
console.log("Clicked:", id);
}, []);
return (
<div>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as any)}>
<option>name</option>
<option>price</option>
</select>
{sorted.map((product) => (
<MemoizedProductCard
key={product.id}
product={product}
onClick={handleProductClick}
/>
))}
</div>
);
}<details>
<summary>📚 More Examples</summary>
// Example 2: Advanced memoization with complex comparison
interface TableProps {
data: Row[];
onRowClick: (row: Row) => void;
filters: Filter[];
sorting: SortConfig;
}
const Table = memo<TableProps>(
({ data, onRowClick, filters, sorting }) => {
return (
<table>
<tbody>
{data.map((row) => (
<tr key={row.id} onClick={() => onRowClick(row)}>
<td>{row.name}</td>
<td>{row.value}</td>
</tr>
))}
</tbody>
</table>
);
},
(prev, next) => {
// Complex comparison: only re-render if data changed structurally
if (prev.data.length !== next.data.length) return false;
if (prev.sorting.column !== next.sorting.column) return false;
if (prev.filters.length !== next.filters.length) return false;
// Data itself might be same reference
for (let i = 0; i < prev.data.length; i++) {
if (prev.data[i].id !== next.data[i].id) return false;
}
return true;
}
);</details>
Props spreading (the spread operator) is powerful but must be used carefully. Spreading all props can hide prop dependencies and make components harder to understand. Reserved patterns exist to handle spreading safely.
// Problematic: spreading all props hides what's actually used
function BadButton(props: any) {
return <button {...props}>{props.children}</button>;
}
// Better: explicitly declare which props are used
interface SafeButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary";
size?: "sm" | "md" | "lg";
}
function SafeButton({
variant = "primary",
size = "md",
children,
...rest
}: SafeButtonProps) {
const sizeClasses = {
sm: "px-2 py-1 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg",
};
return (
<button
className={`btn btn-${variant} ${sizeClasses[size]}`}
{...rest}
>
{children}
</button>
);
}
// Usage: can pass any button attribute
<SafeButton variant="primary" size="lg" disabled onClick={handleClick}>
Click me
</SafeButton>Ready to practice? Challenges | Next: Props Patterns & Composition
Resources
Ojasa Mirai
Master AI-powered development skills through structured learning, real projects, and verified credentials. Whether you're upskilling your team or launching your career, we deliver the skills companies actually need.
Learn Deep • Build Real • Verify Skills • Launch Forward