React – Component Rules
Components are the fundamental building block of every Cygnus Dynamics front-end. These rules define how they are written, structured, and typed. They apply to all React projects regardless of framework (Next.js, Vite, or otherwise).
Quick Reference
| Rule | Correct | Incorrect |
|---|---|---|
| Component type | Functional with hooks | Class components |
| Language | TypeScript — always | Plain JavaScript |
| File name | UserCard.tsx (PascalCase) |
userCard.js, user-card.tsx |
| One per file | One component per file | Multiple exported components |
| Component size | Under 200 lines | Over 300 lines — split it |
| Default export | Named function, then export default |
Anonymous default export |
| Props | Explicit TypeScript interface | Untyped or any |
Component Structure
Every component follows the same top-to-bottom order inside the file:
1. Imports
2. TypeScript interfaces (Props type)
3. Component function
4. Sub-components (if small and tightly coupled)
5. export default
// ✅ Correct — consistent structure
import { useState } from 'react'
import { formatCurrency } from '@/utils/currency'
import type { Order } from '@/types/order'
interface OrderCardProps {
order: Order
onCancel: (orderId: string) => void
isLoading?: boolean
}
const OrderCard = ({ order, onCancel, isLoading = false }: OrderCardProps) => {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="order-card">
<h3>{order.reference}</h3>
<p>{formatCurrency(order.total, order.currency)}</p>
<button
onClick={() => setIsExpanded(prev => !prev)}
aria-expanded={isExpanded}
>
{isExpanded ? 'Hide details' : 'Show details'}
</button>
{isExpanded && <OrderDetails items={order.items} />}
<button
onClick={() => onCancel(order.id)}
disabled={isLoading}
aria-label={`Cancel order ${order.reference}`}
>
Cancel
</button>
</div>
)
}
export default OrderCard
// ❌ Incorrect — anonymous export, no types, class component
export default ({ orderId, cancel }) => { // anonymous — stack traces are unreadable
return <div>{orderId}</div>
}
TypeScript Props
Every component must have an explicit TypeScript interface for its props. Never use any, object, or omit types entirely.
// ✅ Correct — explicit interface with JSDoc on non-obvious props
interface UserAvatarProps {
/** The user's full name — used for the alt text and initials fallback */
name: string
/** URL of the user's profile image. Falls back to initials if undefined */
imageUrl?: string
size?: 'sm' | 'md' | 'lg'
/** Called when the avatar is clicked — omit to render non-interactive */
onClick?: () => void
}
const UserAvatar = ({ name, imageUrl, size = 'md', onClick }: UserAvatarProps) => {
// ...
}
// ✅ For components that wrap HTML elements — extend the native element's props
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger'
isLoading?: boolean
}
const Button = ({ variant = 'primary', isLoading, children, ...props }: ButtonProps) => (
<button
{...props}
disabled={isLoading || props.disabled}
className={`btn btn--${variant}`}
>
{isLoading ? <Spinner /> : children}
</button>
)
// ❌ Incorrect — no types, any, or object
const UserAvatar = ({ name, imageUrl, size, onClick }: any) => { }
const UserAvatar = (props: object) => { }
const UserAvatar = (props) => { } // implicit any
Naming Conventions
// ✅ Component — PascalCase
const OrderSummary = () => { }
const UserProfileCard = () => { }
// ✅ Props interface — ComponentNameProps
interface OrderSummaryProps { }
interface UserProfileCardProps { }
// ✅ Event handler props — on + PascalCase verb
interface FormProps {
onSubmit: (data: FormData) => void
onChange: (field: string, value: string) => void
onCancel: () => void
}
// ✅ Boolean props — is / has / can prefix
interface DialogProps {
isOpen: boolean
isLoading?: boolean
hasError?: boolean
canClose?: boolean
}
// ✅ File name matches component name — exactly
// Component: UserProfileCard → File: UserProfileCard.tsx
// ❌ Incorrect naming
const orderSummary = () => { } // camelCase — not a component
const Order_Summary = () => { } // underscore — not React convention
interface Props { } // too generic — which component's props?
Component Size and Splitting
A component that exceeds 200 lines is doing too much. Split by extracting:
- Sub-components — discrete visual sections that could be reused
- Custom hooks — stateful logic and side effects
- Helper functions — pure calculations and transformations
// ❌ Incorrect — one component doing everything
const CheckoutPage = () => {
const [items, setItems] = useState([])
const [address, setAddress] = useState({})
const [payment, setPayment] = useState({})
const [isLoading, setIsLoading] = useState(false)
const [errors, setErrors] = useState({})
// 50 lines of validation logic
// 40 lines of address form
// 40 lines of payment form
// 30 lines of order summary
// ... 200+ lines total
}
// ✅ Correct — orchestrator delegates to focused components and hooks
const CheckoutPage = () => {
const { items, total } = useCartItems()
const { address, setAddress, addressErrors } = useAddressForm()
const { payment, setPayment, paymentErrors } = usePaymentForm()
const { submitOrder, isSubmitting } = useCheckoutSubmit()
return (
<div className="checkout">
<OrderSummary items={items} total={total} />
<AddressForm value={address} onChange={setAddress} errors={addressErrors} />
<PaymentForm value={payment} onChange={setPayment} errors={paymentErrors} />
<CheckoutActions onSubmit={submitOrder} isLoading={isSubmitting} />
</div>
)
}
Server Components vs Client Components (Next.js 14+)
In Next.js 14+ projects, components are Server Components by default. Only add 'use client' when the component genuinely needs client-side features.
Default → Server Component Add 'use client' when you need →
────────────────────────────────────────────────────────────
No JavaScript sent to browser useState / useReducer
Runs on the server useEffect / useRef
Can fetch data directly Browser APIs (window, document)
Can access backend/DB Event listeners
Better performance Third-party client-only libraries
// ✅ Server Component — no directive needed, fetches data directly
// app/orders/page.tsx
import { getOrders } from '@/lib/orders'
import OrderList from './OrderList'
const OrdersPage = async () => {
const orders = await getOrders() // direct server-side data access
return <OrderList orders={orders} />
}
export default OrdersPage
// ✅ Client Component — only when interactivity is required
// components/SearchBar.tsx
'use client'
import { useState } from 'react'
interface SearchBarProps {
onSearch: (query: string) => void
}
const SearchBar = ({ onSearch }: SearchBarProps) => {
const [query, setQuery] = useState('')
return (
<input
value={query}
onChange={e => {
setQuery(e.target.value)
onSearch(e.target.value)
}}
placeholder="Search orders..."
aria-label="Search orders"
/>
)
}
export default SearchBar
Push 'use client' down the tree
The 'use client' boundary should be as low in the component tree as possible. Wrap only the interactive leaf components — not entire pages or layouts. This maximises the amount of code that runs on the server and reduces the JavaScript bundle sent to the browser.
// ❌ Incorrect — entire page is client-side just for one interactive button
'use client'
const OrdersPage = () => {
const [isFilterOpen, setIsFilterOpen] = useState(false)
// all this data fetching now runs client-side unnecessarily
const { data: orders } = useQuery(...)
return (
<div>
<button onClick={() => setIsFilterOpen(true)}>Filter</button>
{orders.map(o => <OrderCard key={o.id} order={o} />)}
</div>
)
}
// ✅ Correct — only the button is a Client Component
// OrdersPage remains a Server Component
const OrdersPage = async () => {
const orders = await getOrders()
return (
<div>
<FilterButton /> {/* 'use client' component — just this button */}
{orders.map(o => <OrderCard key={o.id} order={o} />)}
</div>
)
}
Error Boundaries
Every route-level component and every component that fetches data must be wrapped in an Error Boundary. Unhandled render errors without a boundary crash the entire page.
Use react-error-boundary — do not write your own class-based boundary.
// ✅ Correct — route-level error boundary
// app/orders/page.tsx (Next.js app router — uses error.tsx convention)
// error.tsx is the automatic error boundary for the route segment
// For non-Next.js projects or component-level boundaries:
import { ErrorBoundary } from 'react-error-boundary'
interface ErrorFallbackProps {
error: Error
resetErrorBoundary: () => void
}
const OrderErrorFallback = ({ error, resetErrorBoundary }: ErrorFallbackProps) => (
<div role="alert">
<p>Something went wrong loading your orders.</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
const OrdersSection = () => (
<ErrorBoundary FallbackComponent={OrderErrorFallback}>
<OrderList />
</ErrorBoundary>
)
Never leave data-fetching components unwrapped
Any component that throws during render (including async data fetching errors surfaced by TanStack Query or SWR) must have an Error Boundary ancestor. Without one, the entire React tree unmounts on error.
State Management Decision Guide
Choose the right tool for each type of state. Using the wrong tool for the job is one of the most common sources of complexity in React codebases.
| State type | Tool | When to use |
|---|---|---|
| Local UI state | useState |
Toggles, form inputs, open/closed — state owned by one component |
| Complex local state | useReducer |
Multiple related fields that update together; state machines |
| Shared UI state | useContext |
Theme, locale, auth user — read frequently, updated rarely |
| Server / async state | TanStack Query | Data fetched from an API — loading, caching, refetching, mutations |
| Global client state | Zustand | Shopping cart, multi-step form state, notifications — updated frequently |
| URL state | Search params | Filters, pagination, tab selection — shareable and bookmarkable |
// ✅ Local UI state — useState
const [isOpen, setIsOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
// ✅ Server state — TanStack Query
const { data: orders, isLoading, error } = useQuery({
queryKey: ['orders', userId],
queryFn: () => fetchOrders(userId),
})
// ✅ Mutation — TanStack Query
const { mutate: cancelOrder, isPending } = useMutation({
mutationFn: (orderId: string) => api.cancelOrder(orderId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['orders'] }),
})
// ✅ Global client state — Zustand
const useCartStore = create<CartStore>(set => ({
items: [],
addItem: (item) => set(state => ({ items: [...state.items, item] })),
removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
}))
// ❌ Wrong tool — using useState for server data
const [orders, setOrders] = useState([])
useEffect(() => {
fetchOrders(userId).then(setOrders)
}, [userId])
// This reinvents TanStack Query badly — no loading state, no error state,
// no caching, no deduplication, no refetch on focus
Accessibility Baseline
All components must meet these minimum accessibility requirements. These are enforced via eslint-plugin-jsx-a11y in CI.
// ✅ Images — always provide meaningful alt text
<img src={user.avatarUrl} alt={`${user.name}'s profile picture`} />
<img src={decorativeDivider} alt="" role="presentation" /> // decorative — empty alt
// ✅ Icon buttons — always provide aria-label when there is no visible text
<button onClick={onClose} aria-label="Close dialog">
<XIcon aria-hidden="true" />
</button>
// ✅ Form inputs — always associate labels
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-describedby={emailError ? 'email-error' : undefined}
aria-invalid={!!emailError}
/>
{emailError && (
<p id="email-error" role="alert">{emailError}</p>
)}
// ✅ Interactive elements — keyboard accessible, correct role
<div
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={e => e.key === 'Enter' && handleClick()}
aria-pressed={isActive}
>
Toggle
</div>
// Better — use a native <button> which is keyboard accessible by default
<button onClick={handleClick} aria-pressed={isActive}>Toggle</button>
// ✅ Dynamic content — announce changes to screen readers
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
// ❌ Missing accessibility
<img src={user.avatar} /> // no alt
<div onClick={handleClose}>✕</div> // not keyboard accessible, no label
<input type="text" placeholder="Enter email" /> // placeholder is not a label
Required eslint-plugin-jsx-a11y rules — must be enabled in every project's .eslintrc:
Composition Over Configuration
Prefer composing components with children and render props over a single component with many conditional props.
// ❌ Incorrect — single component with 8 boolean flags is hard to reason about
<Modal
title="Confirm cancellation"
showCloseButton={true}
showFooter={true}
primaryButtonLabel="Yes, cancel"
secondaryButtonLabel="Keep order"
onPrimary={handleCancel}
onSecondary={handleKeep}
isDangerous={true}
/>
// ✅ Correct — composable API with children
<Modal>
<Modal.Header onClose={handleClose}>Confirm cancellation</Modal.Header>
<Modal.Body>
<p>Are you sure you want to cancel order #{order.reference}?</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleKeep}>Keep order</Button>
<Button variant="danger" onClick={handleCancel}>Yes, cancel</Button>
</Modal.Footer>
</Modal>
Tooling Quick Reference
| Tool | Purpose | Config |
|---|---|---|
ESLint + eslint-plugin-react |
Component rules, hooks rules | .eslintrc |
| eslint-plugin-jsx-a11y | Accessibility lint rules | .eslintrc |
| eslint-plugin-react-hooks | Enforces hooks rules, exhaustive-deps | .eslintrc |
| Prettier | Auto-formatting JSX/TSX | .prettierrc |
TypeScript strict mode |
Full type safety on props and state | tsconfig.json |
| react-error-boundary | Error boundary utility | npm install react-error-boundary |