React Component Development
Patterns for building composable, accessible, well-structured React components.
Core Principles
-
Composition over configuration - Props enable customization, not enumerate options
-
Forwarding refs - Components that render DOM elements forward refs
-
Accessibility first - Keyboard, screen readers, reduced motion
-
Predictable APIs - Consistent prop patterns across components
Component Template
import { forwardRef, type ComponentPropsWithoutRef } from "react" import { cn } from "@/lib/utils"
type ButtonProps = ComponentPropsWithoutRef<"button"> & { variant?: "default" | "outline" | "ghost" size?: "sm" | "md" | "lg" }
const Button = forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant = "default", size = "md", ...props }, ref) => { return ( <button ref={ref} className={cn( "base-styles", variantStyles[variant], sizeStyles[size], className )} {...props} /> ) } ) Button.displayName = "Button"
export { Button, type ButtonProps }
Prop Design
Always Include
Prop Type Purpose
className
string
Style composition
children
ReactNode
Content (when applicable)
...rest
native props Forward all valid HTML attributes
Variant Props
// Good: Union of literal types variant?: "default" | "destructive" | "outline"
// Bad: Boolean props that multiply isPrimary?: boolean isDestructive?: boolean isOutline?: boolean
Render Props / Slots
For complex customization:
type DialogProps = { trigger?: ReactNode title: ReactNode description?: ReactNode children: ReactNode footer?: ReactNode }
forwardRef Patterns
When to Use
-
Component renders a single DOM element
-
Component wraps another forwardRef component
-
Users might need to call .focus() , measure, or attach refs
When to Skip
-
Component renders multiple root elements
-
Component is purely logic (hooks)
-
Internal-only component never exposed to consumers
Extracting Ref Type
// From DOM element forwardRef<HTMLDivElement, Props>
// From another component forwardRef<ComponentRef<typeof OtherComponent>, Props>
File Organization
components/ └── button/ ├── index.ts # Re-export: export { Button } from "./button" ├── button.tsx # Implementation ├── button.test.tsx # Tests └── use-button-state.ts # Complex state logic (if needed)
index.ts Pattern
export { Button, type ButtonProps } from "./button"
Keep index.ts as pure re-exports. No logic.
Accessibility Checklist
Keyboard
-
All interactive elements focusable
-
Focus order matches visual order
-
Focus visible (outline or ring)
-
Escape closes modals/dropdowns
-
Enter/Space activates buttons
-
Arrow keys for menu navigation
ARIA
// Buttons with icons only <button aria-label="Close dialog"> <XIcon aria-hidden="true" /> </button>
// Loading states <button disabled aria-busy={isLoading}> {isLoading ? <Spinner /> : "Submit"} </button>
// Expandable content <button aria-expanded={isOpen} aria-controls="panel-id"> Toggle </button>
Reduced Motion
const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)")
// Or in CSS @media (prefers-reduced-motion: reduce) {
- { animation-duration: 0.01ms !important; } }
State Management
Local State
Use useState for:
-
UI state (open/closed, selected)
-
Form inputs (controlled)
-
Ephemeral data (hover, focus)
Derived State
// Bad: useEffect to sync
const [fullName, setFullName] = useState("")
useEffect(() => {
setFullName(${firstName} ${lastName})
}, [firstName, lastName])
// Good: useMemo
const fullName = useMemo(
() => ${firstName} ${lastName},
[firstName, lastName]
)
// Best: Just compute it (if cheap)
const fullName = ${firstName} ${lastName}
Complex State
// useReducer for multi-field updates const [state, dispatch] = useReducer(reducer, initialState)
// Or extract to custom hook const dialog = useDialogState()
Event Handlers
Prop Naming
// Internal handler const handleClick = () => { ... }
// Prop callbacks: on[Event] type Props = { onClick?: () => void onOpenChange?: (open: boolean) => void onValueChange?: (value: string) => void }
Composing Handlers
const Button = forwardRef<HTMLButtonElement, ButtonProps>( ({ onClick, ...props }, ref) => { const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { // Internal logic trackClick() // Call user's handler onClick?.(e) }
return <button ref={ref} onClick={handleClick} {...props} />
} )
Testing Approach
What to Test
-
User interactions (click, type, submit)
-
Accessibility (keyboard nav, ARIA states)
-
Conditional rendering
-
Error states
What NOT to Test
-
Implementation details (internal state values)
-
Styling (unless critical to function)
-
Third-party library internals
Test Structure
describe("Button", () => { it("calls onClick when clicked", async () => { const handleClick = vi.fn() render(<Button onClick={handleClick}>Click me</Button>)
await userEvent.click(screen.getByRole("button"))
expect(handleClick).toHaveBeenCalledOnce()
})
it("is disabled when disabled prop is true", () => { render(<Button disabled>Disabled</Button>)
expect(screen.getByRole("button")).toBeDisabled()
}) })
Anti-Patterns
Prop Drilling
// Bad: Passing props through many layers <Parent value={x} onChange={y}> <Child value={x} onChange={y}> <GrandChild value={x} onChange={y} />
// Better: Context for deep trees <ValueContext.Provider value={{ x, onChange: y }}> <Parent> <Child> <GrandChild /> {/* useContext inside */}
Premature Abstraction
// Bad: Generic component nobody asked for <FlexContainer direction="column" gap={4} align="center" justify="between">
// Good: Specific component for the use case <CardHeader>
Boolean Prop Explosion
// Bad <Button primary large disabled loading>
// Good <Button variant="primary" size="lg" disabled isLoading>
Quick Reference
Pattern When
forwardRef
Wrapping DOM elements
ComponentPropsWithoutRef<"tag">
Inheriting native props
cn()
Merging classNames
as const
Literal type inference
useImperativeHandle
Custom ref APIs (rare)
React.Children
Manipulating children (avoid if possible)