Component Design — Rules and Conventions
1. Philosophy
- Composition over inheritance — Build UI by combining small, focused components.
- Props as API — Design props like a public API: minimal, explicit, type-safe.
- Controlled by default — Prefer controlled components. Uncontrolled only for leaf inputs.
- Accessibility built-in — Semantic HTML, ARIA when needed, keyboard navigation default.
- Performance conscious —
React.memo,useMemo,useCallbackonly when measured.
2. Atomic Design (Compact)
| Level | Description | Example |
|---|---|---|
| Atoms | Indivisible UI primitives | Button, Input, Label, Icon |
| Molecules | Simple compositions | FormField (Label + Input + Error), SearchBox |
| Organisms | Complex sections | Header, Sidebar, DataTable, CardList |
| Templates | Page structure without data | DashboardLayout, AuthLayout |
| Pages | Templates + real data | DashboardPage, SettingsPage |
Rules
- Atoms only in
components/atoms/— no business logic - Molecules compose atoms + minimal logic
- Organisms = business logic + data fetching (via props/hooks)
- Templates/Pages in
app/orpages/(Next.js) / route files (TanStack Router)
3. Composition vs Inheritance
Composition (Preferred)
// ✅ Composition
interface CardProps {
header?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
}
export function Card({ header, children, footer }: CardProps) {
return (
<article className="card">
{header && <header className="card-header">{header}</header>}
<div className="card-body">{children}</div>
{footer && <footer className="card-footer">{footer}</footer>}
</article>
);
}
// Usage
<Card header={<CardTitle />}>
<CardContent />
</Card>;
Rules Composition vs Inheritance
- Slots over props —
children+ named slots (header,footer) overrenderPropfunctions - No inheritance — no
extends BaseComponent - Props drilling max 2 levels — use Context for deeper
4. Slots Pattern
// components/Modal.tsx
interface ModalProps {
trigger: React.ReactNode;
title: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
}
export function Modal({ trigger, title, children, footer }: ModalProps) {
const [open, setOpen] = useState(false);
return (
<>
{typeof trigger === "function" ? (
trigger({ onClick: () => setOpen(true) })
) : (
<button => setOpen(true)}>{trigger}</button>
)}
{open && (
<Dialog open={open}
<DialogContent>
<DialogHeader>{title}</DialogHeader>
<DialogBody>{children}</DialogBody>
{footer && <DialogFooter>{footer}</DialogFooter>}
</DialogContent>
</Dialog>
)}
</>
);
}
Rules Slot Pattern
- Named slots —
header,footer,childrenoverrenderProp={...} - Default slots — optional with sensible defaults
- Type-safe —
React.ReactNodefor slots
5. Props API Design
Interface Design
// ✅ Good: explicit, typed, documented
interface ButtonProps {
/** Visual variant */
variant: 'primary' | 'secondary' | 'ghost' | 'destructive'
/** Size variant */
size?: 'sm' | 'md' | 'lg'
/** Disables interaction */
disabled?: boolean
/** Click handler */
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void
/** Button content */
children: React.ReactNode
/** Additional CSS classes */
className?: string
}
// ❌ Bad: vague, loose types
interface BadButtonProps {
type?: string
onClick?: () => void
children?: any
className?: string
...rest: any
}
Rules Props API Design
- Required props first — required before optional
- Descriptive names —
onSubmitnotonClickfor forms - Single event handler type —
React.MouseEvent<HTMLButtonElement> classNamelast — for composition- No
any— ever - JSDoc for non-obvious props —
@paramfor complex props
6. Compound Components
// components/Select.tsx
interface SelectContextValue {
value: string
onChange: (value: string) => void
disabled: boolean
}
const SelectContext = createContext<SelectContextValue | null>(null)
function Select({ value, onChange, disabled, children }: SelectProps) {
return (
<SelectContext.Provider value={{ value, onChange, disabled }}>
<div className="select">{children}</div>
</SelectContext.Provider>
)
}
function SelectTrigger({ children, className }: { children: React.ReactNode; className?: string }) {
const ctx = useContext(SelectContext)
return <button className={className} disabled={ctx?.disabled} => setOpen(true)}>
{children}
</button>
}
function SelectOptions({ children }: { children: React.ReactNode }) {
return <ul role="listbox">{children}</ul>
}
function SelectOption({ value, children }: { value: string; children: React.ReactNode }) {
const ctx = useContext(SelectContext)
return (
<li role="option" aria-selected={ctx?.value === value} => ctx?.onChange(value)}>
{children}
</li>
)
}
Select.Trigger = SelectTrigger
Select.Options = SelectOptions
Select.Option = SelectOption
// Usage
<Select value={value}
<Select.Trigger>Select...</Select.Trigger>
<Select.Options>
<Select.Option value="a">Option A</Select.Option>
<Select.Option value="b">Option B</Select.Option>
</Select.Options>
</Select>
Rules Compound Components
- Implicit state sharing — via Context
- Type-safe subcomponents —
Select.Trigger,Select.Option - Flexible composition — consumer controls layout
7. Polymorphic Components (as prop)
// components/Box.tsx
type PolymorphicRef<C extends React.ElementType> =
React.ComponentPropsWithRef<C>['ref']
type PolymorphicProps<C extends React.ElementType, Props = {}> =
Props & { as?: C } & Omit<React.ComponentPropsWithoutRef<C>, keyof Props>
type BoxProps<C extends React.ElementType = 'div'> = PolymorphicProps<C, {
children: React.ReactNode
padding?: 'none' | 'sm' | 'md' | 'lg'
}>
export function Box<C extends React.ElementType = 'div'>({
as,
children,
padding,
className,
...props
}: BoxProps<C>) {
const Component = as || 'div'
return <Component className={cn('box', padding && `p-${padding}`, className)} {...props}>
{children}
</Component>
}
// Usage
<Box as="section" padding="md">Content</Box>
<Box as={Link} href="/about" padding="sm">About</Box>
Rules Polymorphic Components
- Default
as='div'— semantic default - Forward ref —
React.ComponentPropsWithRef - Omit conflicting props —
Omit<ComponentPropsWithoutRef<C>, keyof Props>
8. Controlled vs Uncontrolled
Controlled (Preferred)
interface InputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function Input({ value, onChange, ...props }: InputProps) {
return (
<input
value={value}
=> onChange(e.target.value)}
{...props}
/>
);
}
Uncontrolled (Leaf Inputs Only)
interface UncontrolledInputProps {
defaultValue?: string;
onChange?: (value: string) => void;
ref?: React.Ref<HTMLInputElement>;
}
export function UncontrolledInput({
defaultValue,
onChange,
ref,
...props
}: UncontrolledInputProps) {
const internalRef = useRef<HTMLInputElement>(null);
const forwardedRef = ref || internalRef;
return (
<input
defaultValue={defaultValue}
=> onChange?.(e.target.value)}
ref={forwardedRef}
{...props}
/>
);
}
Rules Controlled vs Uncontrolled
- Default to controlled — React owns state
- Uncontrolled only for:
<input type="file">, leaf inputs without validation defaultValuenotvalue— for uncontrolledreffor imperative access —focus(),select()
9. Headless vs Styled Components
Headless (Logic Only)
// hooks/useToggle.ts
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
return { on, toggle: () => setOn((o) => !o), setOn };
}
// Consumer provides ALL UI
function MyToggle() {
const { on, toggle } = useToggle();
return (
<label>
<input type="checkbox" checked={on} />
<span>{on ? "ON" : "OFF"}</span>
</label>
);
}
Styled (Opinionated)
// components/Toggle.tsx
export function Toggle({
checked,
onChange,
label,
}: {
checked: boolean;
onChange: (v: boolean) => void;
label: string;
}) {
return (
<label className="toggle">
<input
type="checkbox"
checked={checked}
=> onChange(e.target.checked)}
/>
<span className="toggle-thumb" />
<span className="toggle-label">{label}</span>
</label>
);
}
Rules Headless vs Styled Components
- Headless for design system primitives — maximum flexibility
- Styled for app-specific components — consistent look
- Export both — headless hook + styled component
10. useControllableState
// hooks/useControllableState.ts
export function useControllableState<T>({
value: controlledValue,
defaultValue,
onChange,
}: {
value?: T;
defaultValue: T;
onChange?: (value: T) => void;
}) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : uncontrolledValue;
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const nextValue =
typeof next === "function" ? (next as (prev: T) => T)(value) : next;
if (!isControlled) setUncontrolledValue(nextValue);
onChange?.(nextValue);
},
[isControlled, value, onChange],
);
return [value, setValue] as const;
}
10. Performance Optimization
Memoization (Only When Measured)
// ✅ Measured bottleneck → memoize
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
return (
<ul>
{items.map((item) => (
<ListItem key={item.id} item={item} />
))}
</ul>
);
});
// Stable callback for memoized children
function Parent() {
const handleSelect = useCallback((id) => {
/* ... */
}, []);
return <ExpensiveList items={items} />;
}
Rules Performance Optimization
- Profile first — React DevTools Profiler
React.memo— shallow prop comparisonuseCallback/useMemo— stable references for memoized children- Don't over-memoize — cost of comparison > render cost for small components
11. Accessibility (Reference)
Full accessibility rules: see
accessibilityskill.
Essentials
- Semantic HTML —
<button>,<nav>,<main>,<article> - Labels —
<label htmlFor>,aria-label,aria-labelledby - Focus management — visible focus, logical order, focus trap in modals
- ARIA — roles, states, properties when native HTML insufficient
12. Testing Components (Reference)
Full testing patterns: see
testingskill.
Essentials Accessibility
// Test behavior, not implementation
it("opens modal on button click", async () => {
render(<Modal trigger={<button>Open</button>}>Content</Modal>);
await userEvent.click(screen.getByRole("button", { name: /open/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
13. Storybook (Reference)
Storybook patterns: see
testingskill.
// Button.stories.tsx
export default { component: Button, tags: ["autodocs"] };
export const Primary = { args: { variant: "primary", children: "Button" } };
export const Secondary = { args: { variant: "secondary", children: "Button" } };
14. Documentation
/**
* A flexible button component supporting multiple variants and sizes.
*
* @example
* ```tsx
* <Button variant="primary"
* ```
*
* @param variant - Visual style variant
* @param size - Size variant
* @param disabled - Disables interaction
* @param onClick - Click handler
* @param children - Button content
*/
export function Button({ variant = 'primary', size = 'md', disabled, onClick, children }: ButtonProps) { ... }
15. Methodology
Before using ANY component pattern not documented in this skill:
- MCP Context7 (priority):
context7_resolve-library-id+context7_query-docsfor React, Radix UI, Headless UI. - Official docs: react.dev, radix-ui.com — verify current APIs.
- Project config:
components/,tsconfig.json,package.json— verify against actual setup. - HARD RULE: If not in this skill AND cannot be verified against 2 authoritative sources → DO NOT USE IT. Document as assumption or risk in report to orchestrator.
16. Prohibitions
- ❌ Do not use inheritance — composition only
- ❌ Do not pass
anyin props — strict typing - ❌ Do not skip
React.memoon leaf components with stable props - ❌ Do not use
childrenas function (render props) — slots pattern preferred - ❌ Do not mix headless logic with styled UI in same component — separate
- ❌ Do not skip accessibility — semantic HTML, labels, focus
- ❌ Do not use
classNamefor variant logic — usevariantprop - ❌ Do not expose internal state — encapsulate via props
17. References
Note: For React patterns, see React Note: For TypeScript rules, see TypeScript Note: For JavaScript conventions, see JavaScript Note: For HTML conventions, see HTML Note: For CSS conventions, see CSS Note: For Accessibility, see Accessibility Note: For Testing patterns, see Testing Note: For Performance, see Performance
Last updated: 2026-08