Architecture
Component Design
Composition patterns, prop design, and building reusable components
Component Design
Well-designed components are the foundation of maintainable UI. This covers patterns for building flexible, reusable components.
Component Categories
Presentational vs Container
// Presentational - pure UI, receives all data via props
function UserCard({ name, avatar, role, onEdit }: UserCardProps) {
return (
<div className="user-card">
<img src={avatar} alt={name} />
<h3>{name}</h3>
<span>{role}</span>
<button onClick={onEdit}>Edit</button>
</div>
);
}
// Container - handles logic, passes data to presentational
function UserCardContainer({ userId }: { userId: string }) {
const { data: user, isLoading } = useUser(userId);
const { mutate: updateUser } = useUpdateUser();
if (isLoading) return <UserCardSkeleton />;
return (
<UserCard
name={user.name}
avatar={user.avatar}
role={user.role}
onEdit={() => updateUser(user)}
/>
);
}Component Hierarchy
Component Types
├── Primitives
│ └── Button, Input, Text, Icon
├── Compounds
│ └── Card, Modal, Dropdown, Tabs
├── Features
│ └── UserProfile, ProductCard, SearchBar
└── Pages/Views
└── Dashboard, Settings, ProfileComposition Patterns
Children Pattern
// Flexible composition via children
interface CardProps {
children: React.ReactNode;
className?: string;
}
function Card({ children, className }: CardProps) {
return <div className={cn('card', className)}>{children}</div>;
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>;
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>;
}
// Usage - flexible layout
<Card>
<CardHeader>
<h2>Title</h2>
<Badge>New</Badge>
</CardHeader>
<CardBody>
<p>Content goes here</p>
</CardBody>
</Card>Compound Components
// Context-based compound components
const TabsContext = createContext<TabsContextValue | null>(null);
function Tabs({ children, defaultValue, onChange }: TabsProps) {
const [value, setValue] = useState(defaultValue);
return (
<TabsContext.Provider value={{ value, onChange: onChange ?? setValue }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabsList({ children }: { children: React.ReactNode }) {
return <div className="tabs-list" role="tablist">{children}</div>;
}
function TabsTrigger({ value, children }: TabsTriggerProps) {
const context = useContext(TabsContext);
const isActive = context?.value === value;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => context?.onChange(value)}
className={cn('tab-trigger', isActive && 'active')}
>
{children}
</button>
);
}
function TabsContent({ value, children }: TabsContentProps) {
const context = useContext(TabsContext);
if (context?.value !== value) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach as static properties
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Content = TabsContent;
// Usage
<Tabs defaultValue="tab1">
<Tabs.List>
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</Tabs>Render Props
// Render prop for flexible rendering
interface MouseTrackerProps {
render: (position: { x: number; y: number }) => React.ReactNode;
}
function MouseTracker({ render }: MouseTrackerProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMove);
return () => window.removeEventListener('mousemove', handleMove);
}, []);
return <>{render(position)}</>;
}
// Usage
<MouseTracker render={({ x, y }) => (
<div>Mouse is at ({x}, {y})</div>
)} />
// Alternative: children as function
interface MouseTrackerProps {
children: (position: { x: number; y: number }) => React.ReactNode;
}
<MouseTracker>
{({ x, y }) => <div>Mouse is at ({x}, {y})</div>}
</MouseTracker>Slots Pattern (Vue-style in React)
interface CardSlots {
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
function Card({ header, footer, children }: CardSlots) {
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// Usage
<Card
header={<h2>Card Title</h2>}
footer={<Button>Action</Button>}
>
<p>Main content</p>
</Card>Props Design
Props Interface Guidelines
// Good props interface
interface ButtonProps {
// Required props first
children: React.ReactNode;
// Variants as union types
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
// Boolean props (default false)
disabled?: boolean;
loading?: boolean;
// Callbacks with consistent naming
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onFocus?: (event: React.FocusEvent<HTMLButtonElement>) => void;
// Allow extending native attributes
className?: string;
type?: 'button' | 'submit' | 'reset';
}
// Extend HTML attributes for full flexibility
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}Polymorphic Components
type PolymorphicProps<E extends React.ElementType> = {
as?: E;
children: React.ReactNode;
} & Omit<React.ComponentPropsWithoutRef<E>, 'as' | 'children'>;
function Text<E extends React.ElementType = 'span'>({
as,
children,
...props
}: PolymorphicProps<E>) {
const Component = as || 'span';
return <Component {...props}>{children}</Component>;
}
// Usage
<Text>Default span</Text>
<Text as="p">Paragraph</Text>
<Text as="h1">Heading</Text>
<Text as="a" href="/link">Link</Text>Default Props
// Using default parameters (recommended)
function Button({
variant = 'primary',
size = 'md',
disabled = false,
children,
...props
}: ButtonProps) {
return (
<button
className={cn('btn', `btn-${variant}`, `btn-${size}`)}
disabled={disabled}
{...props}
>
{children}
</button>
);
}Component Patterns
Controlled vs Uncontrolled
// Uncontrolled - component manages its own state
function UncontrolledInput({ defaultValue, onChange }: Props) {
const [value, setValue] = useState(defaultValue);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
onChange?.(e.target.value);
};
return <input value={value} onChange={handleChange} />;
}
// Controlled - parent manages state
function ControlledInput({ value, onChange }: Props) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
// Hybrid - support both modes
function Input({ value: controlledValue, defaultValue, onChange }: Props) {
const [internalValue, setInternalValue] = useState(defaultValue ?? '');
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internalValue;
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
if (!isControlled) {
setInternalValue(e.target.value);
}
onChange?.(e.target.value);
};
return <input value={value} onChange={handleChange} />;
}Higher-Order Components (HOC)
// HOC for adding loading state
function withLoading<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithLoadingComponent({
isLoading,
...props
}: P & { isLoading: boolean }) {
if (isLoading) {
return <LoadingSpinner />;
}
return <WrappedComponent {...(props as P)} />;
};
}
// Usage
const UserListWithLoading = withLoading(UserList);
<UserListWithLoading isLoading={loading} users={users} />
// Note: Prefer hooks over HOCs in modern ReactCustom Hooks for Reuse
// Extract reusable logic into hooks
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
// Usage in component
function Modal({ children }) {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle();
return (
<>
<button onClick={open}>Open</button>
{isOpen && (
<div className="modal">
<button onClick={close}>Close</button>
{children}
</div>
)}
</>
);
}Accessibility (A11y)
// Accessible button with proper ARIA
function IconButton({
icon: Icon,
label,
onClick,
disabled,
}: IconButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
aria-label={label}
className="icon-button"
>
<Icon aria-hidden="true" />
</button>
);
}
// Accessible modal
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const titleId = useId();
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="modal-overlay"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="modal-content">
<h2 id={titleId}>{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
×
</button>
</div>
</div>,
document.body
);
}Best Practices
Component Design Guidelines
- Single responsibility - one component, one job
- Prefer composition over prop explosion
- Use TypeScript for prop interfaces
- Make components controlled when parent needs control
- Extract reusable logic into custom hooks
- Test components in isolation
- Document component API with examples
- Ensure accessibility (ARIA, keyboard navigation)