Skip to content

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:

{
  "plugins": ["jsx-a11y"],
  "extends": ["plugin:jsx-a11y/recommended"]
}

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