React Production Engineering
Complete methodology for building production-grade React applications. Covers architecture decisions, component design, state management, performance optimization, testing, and deployment — not just API reference, but engineering methodology with decision frameworks, templates, and scoring systems.
Phase 1: Architecture Assessment
Quick Health Check (score /16)
Architecture Brief
project:
name: ""
type: "" # spa | ssr | hybrid | static
framework: "" # next | remix | vite-spa | astro
scale: "" # small (<20 routes) | medium (20-100) | large (100+)
team_size: "" # solo | small (2-5) | medium (6-15) | large (15+)
current_state:
react_version: "" # 18 | 19
typescript: true
router: "" # react-router | next-app | tanstack-router
state_management: "" # useState | zustand | jotai | redux | tanstack-query
styling: "" # tailwind | css-modules | styled-components | vanilla-extract
testing: "" # vitest | jest | playwright | cypress
ci_cd: "" # github-actions | gitlab-ci | vercel
pain_points: []
goals: []
Framework Selection Decision Matrix
| Factor |
Vite SPA |
Next.js |
Remix |
Astro |
| SEO needed |
❌ |
✅ Best |
✅ Good |
✅ Best |
| Dashboard/app |
✅ Best |
✅ Good |
✅ Good |
❌ |
| Content-heavy |
❌ |
✅ Good |
✅ Good |
✅ Best |
| Team familiarity |
✅ Simple |
⚠️ Learning curve |
⚠️ Web standards |
⚠️ Islands |
| Deployment |
Anywhere |
Vercel optimal |
Anywhere |
Anywhere |
| Bundle size |
You control |
Framework overhead |
Smaller |
Minimal JS |
Decision rules:
- Dashboard/internal tool with no SEO → Vite SPA
- Marketing + app hybrid → Next.js
- Content-first with some interactivity → Astro
- Web-standards-first, nested layouts → Remix
- Default for most SaaS products → Next.js
Phase 2: Project Structure & Conventions
Recommended Feature-Based Structure
src/
├── app/ # Routes/pages (framework-specific)
├── features/ # Feature modules (THE core pattern)
│ ├── auth/
│ │ ├── components/ # Feature-specific components
│ │ ├── hooks/ # Feature-specific hooks
│ │ ├── api/ # API calls & types
│ │ ├── utils/ # Feature utilities
│ │ ├── types.ts # Feature types
│ │ └── index.ts # Public API (barrel export)
│ ├── dashboard/
│ └── settings/
├── shared/ # Cross-feature shared code
│ ├── components/ # Generic UI components
│ │ ├── ui/ # Primitives (Button, Input, Card)
│ │ └── layout/ # Layout components
│ ├── hooks/ # Generic hooks
│ ├── lib/ # Utilities, constants
│ └── types/ # Global types
├── providers/ # Context providers
└── styles/ # Global styles
7 Structure Rules
- Feature isolation — features/ never import from other features directly; use shared/ or events
- Barrel exports — every feature has index.ts that defines its public API
- Colocation — tests, stories, and styles live next to their component
- Max file size — 300 lines. If bigger, split
- Max component size — 50 lines of JSX. If bigger, extract
- No circular deps — enforce with eslint-plugin-import
- Types colocated — feature types in feature, shared types in shared/types
Naming Conventions
Components: PascalCase.tsx (UserProfile.tsx)
Hooks: useCamelCase.ts (useAuth.ts)
Utilities: camelCase.ts (formatCurrency.ts)
Types: PascalCase.ts (User.ts) or types.ts
Constants: SCREAMING_SNAKE.ts (API_ENDPOINTS.ts)
Test files: *.test.tsx (UserProfile.test.tsx)
Story files: *.stories.tsx (Button.stories.tsx)
Phase 3: Component Design Patterns
Component Anatomy Template
// 1. Imports (grouped: react → third-party → internal → types → styles)
import { useState, useCallback, memo } from 'react'
import { clsx } from 'clsx'
import { Button } from '@/shared/components/ui'
import type { User } from '../types'
// 2. Types (exported for reuse)
export interface UserCardProps {
user: User
onEdit?: (id: string) => void
variant?: 'compact' | 'full'
className?: string
}
// 3. Component (named export, not default)
export const UserCard = memo(function UserCard({
user,
onEdit,
variant = 'full',
className,
}: UserCardProps) {
// 4. Hooks first
const [isExpanded, setIsExpanded] = useState(false)
// 5. Derived state (no useEffect for derived!)
const displayName = `${user.firstName} ${user.lastName}`
// 6. Handlers (useCallback for passed-down refs)
const handleEdit = useCallback(() => {
onEdit?.(user.id)
}, [onEdit, user.id])
// 7. Early returns for edge cases
if (!user) return null
// 8. JSX (max 50 lines)
return (
<div className={clsx('rounded-lg border p-4', className)}>
<h3>{displayName}</h3>
{variant === 'full' && <p>{user.bio}</p>}
{onEdit && <Button onClick={handleEdit}>Edit</Button>}
</div>
)
})
Component Composition Patterns
1. Compound Components (for related UI groups)
// Usage: <Tabs><Tabs.List><Tabs.Tab>A</Tabs.Tab></Tabs.List><Tabs.Panel>...</Tabs.Panel></Tabs>
const TabsContext = createContext<TabsContextType | null>(null)
export function Tabs({ children, defaultValue }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultValue)
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
)
}
Tabs.List = TabsList
Tabs.Tab = TabsTab
Tabs.Panel = TabsPanel
2. Render Props (for flexible rendering logic)
export function DataList<T>({ items, renderItem, renderEmpty }: DataListProps<T>) {
if (items.length === 0) return renderEmpty?.() ?? <EmptyState />
return <ul>{items.map((item, i) => <li key={i}>{renderItem(item)}</li>)}</ul>
}
3. Higher-Order Components (for cross-cutting concerns — use sparingly)
export function withAuth<P>(Component: ComponentType<P>) {
return function AuthenticatedComponent(props: P) {
const { user, isLoading } = useAuth()
if (isLoading) return <Spinner />
if (!user) return <Navigate to="/login" />
return <Component {...props} />
}
}
10 Component Rules
- One component per file — always
- Named exports — never default exports (refactoring safety)
- Props interface — always explicit, always exported
- No business logic in components — extract to hooks
- No inline styles — use Tailwind classes or CSS modules
- No string refs — useRef only
- No index as key — use stable identifiers
- Memo strategically — not everywhere, only for expensive renders
- Children over props — prefer composition over configuration
- Accessible by default — semantic HTML, ARIA when needed
Phase 4: State Management Decision Framework
State Type Decision Tree
Is it server data (from API)?
├─ YES → TanStack Query (or SWR) — NEVER Redux/Zustand for server state
│
└─ NO → Is it shared across features?
├─ YES → Is it complex with many actions?
│ ├─ YES → Zustand (or Redux Toolkit if team knows it)
│ └─ NO → Jotai (atomic) or Zustand (simple store)
│
└─ NO → Is it shared within a feature?
├─ YES → Context + useReducer (or Zustand feature store)
└─ NO → useState / useReducer (component-local)
State Management Comparison
| Tool |
Best For |
Bundle |
Learning |
Team Size |
| useState |
Component-local |
0 KB |
None |
Any |
| useReducer |
Complex local state |
0 KB |
Low |
Any |
| Context |
Feature-scoped, low-frequency |
0 KB |
Low |
Any |
| Zustand |
Global client state |
1.1 KB |
Low |
Any |
| Jotai |
Atomic derived state |
3.4 KB |
Medium |
Small-Med |
| TanStack Query |
Server state |
12 KB |
Medium |
Any |
| Redux Toolkit |
Complex global + middleware |
11 KB |
High |
Large |
Server State with TanStack Query
// api/users.ts — query key factory pattern
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: Filters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
// hooks/useUsers.ts
export function useUsers(filters: Filters) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => fetchUsers(filters),
staleTime: 5 * 60 * 1000, // 5 min
placeholderData: keepPreviousData,
})
}
export function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
// Optimistic update
await queryClient.cancelQueries({ queryKey: userKeys.detail(newUser.id) })
const previous = queryClient.getQueryData(userKeys.detail(newUser.id))
queryClient.setQueryData(userKeys.detail(newUser.id), newUser)
return { previous }
},
onError: (err, newUser, context) => {
queryClient.setQueryData(userKeys.detail(newUser.id), context?.previous)
},
onSettled: (data, err, variables) => {
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) })
queryClient.invalidateQueries({ queryKey: userKeys.lists() })
},
})
}
Client State with Zustand
// stores/useUIStore.ts — thin, focused stores
interface UIStore {
sidebarOpen: boolean
theme: 'light' | 'dark' | 'system'
toggleSidebar: () => void
setTheme: (theme: UIStore['theme']) => void
}
export const useUIStore = create<UIStore>()(
persist(
(set) => ({
sidebarOpen: true,
theme: 'system',
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}),
{ name: 'ui-preferences' }
)
)
// Usage: const theme = useUIStore((s) => s.theme) — always use selectors!
5 State Management Rules
- Server state ≠ client state — never mix them in the same store
- Smallest scope possible — useState > Context > Zustand > Redux
- No useEffect for derived state — use useMemo or compute inline
- Selectors always —
useStore(s => s.field) not useStore()
- URL is state — search params, filters, pagination → URL, not React state
Phase 5: Hooks Engineering
Custom Hook Template
// hooks/useDebounce.ts
export function useDebounce<T>(value: T, delayMs: number = 300): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delayMs)
return () => clearTimeout(timer)
}, [value, delayMs])
return debouncedValue
}
Essential Custom Hooks Library
| Hook |
Purpose |
When to Use |
useDebounce |
Debounce value changes |
Search inputs, resize |
useMediaQuery |
Responsive breakpoints |
Conditional rendering |
useLocalStorage |
Persistent local state |
Preferences, drafts |
useIntersection |
Viewport detection |
Lazy load, infinite scroll |
usePrevious |
Track previous value |
Animations, comparisons |
useClickOutside |
Detect outside clicks |
Dropdowns, modals |
useEventListener |
Safe event binding |
Keyboard, scroll, resize |
useToggle |
Boolean state toggle |
Modals, accordions |
Hook Rules (beyond React's rules)
- One concern per hook —
useUserSearch not useEverything
- Return tuple or object — tuple for 1-2 values, object for 3+
- Accept options object —
useDebounce(value, { delay: 300 }) scales better
- Handle cleanup — every subscription/timer needs cleanup in useEffect return
- No hooks in conditions — extract conditional logic into the hook body
- Test hooks independently — use
renderHook from testing-library
Phase 6: TypeScript Integration
Strict Configuration
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
Essential Type Patterns
// 1. Discriminated unions for state machines
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error }
// 2. Polymorphic components
type ButtonProps<C extends ElementType = 'button'> = {
as?: C
variant?: 'primary' | 'secondary'
} & ComponentPropsWithoutRef<C>
export function Button<C extends ElementType = 'button'>({
as,
variant = 'primary',
...props
}: ButtonProps<C>) {
const Component = as || 'button'
return <Component {...props} />
}
// 3. Branded types for IDs
type UserId = string & { __brand: 'UserId' }
type PostId = string & { __brand: 'PostId' }
// 4. Zod for runtime validation
const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'viewer']),
})
type User = z.infer<typeof userSchema>
5 TypeScript Rules
- Zero
any — use unknown and narrow, or generics
- Zod at boundaries — validate all external data (API, forms, URL params)
- Discriminated unions over optional fields —
{ status: 'success'; data: T } not { data?: T; error?: Error }
- Branded types for IDs — prevent
userId being passed where postId expected
- Satisfies over as —
config satisfies Config preserves inference; as Config lies
Phase 7: Performance Optimization
Performance Budget
| Metric |
Target |
Measurement |
| First Contentful Paint |
< 1.8s |
Lighthouse |
| Largest Contentful Paint |
< 2.5s |
Lighthouse |
| Interaction to Next Paint |
< 200ms |
Lighthouse |
| Cumulative Layout Shift |
< 0.1 |
Lighthouse |
| Bundle size (gzipped) |
< 200 KB |
webpack-bundle-analyzer |
| JS execution (main thread) |
< 3s |
Chrome DevTools |
Optimization Priority Stack
| Priority |
Technique |
Impact |
Effort |
| P0 |
Code splitting (route-based) |
🔴 High |
Low |
| P0 |
Image optimization (next/image, srcset) |
🔴 High |
Low |
| P1 |
Tree shaking (named imports) |
🟡 Medium |
Low |
| P1 |
Virtualization for long lists |
🟡 Medium |
Medium |
| P1 |
Debounce expensive operations |
🟡 Medium |
Low |
| P2 |
React.memo on expensive components |
🟢 Low-Med |
Low |
| P2 |
useMemo/useCallback for expensive calculations |
🟢 Low-Med |
Low |
| P3 |
Web Workers for heavy computation |
🟢 Low |
High |
Code Splitting Patterns
// 1. Route-based (automatic with Next.js, manual with React Router)
const Dashboard = lazy(() => import('./features/dashboard'))
const Settings = lazy(() => import('./features/settings'))
// 2. Component-based (heavy components)
const Chart = lazy(() => import('./components/Chart'))
const MarkdownEditor = lazy(() =>
import('./components/MarkdownEditor').then(m => ({ default: m.MarkdownEditor }))
)
// 3. Library-based (heavy third-party)
const { PDFViewer } = await import('@react-pdf/renderer')
React Compiler (React 19+)
// With React Compiler enabled, manual memo/useMemo/useCallback become unnecessary
// The compiler auto-memoizes. Remove manual optimizations:
// ❌ const memoized = useMemo(() => expensiveCalc(data), [data])
// ✅ const memoized = expensiveCalc(data) // compiler handles it
// Enable in babel config:
// plugins: [['babel-plugin-react-compiler', {}]]
Rendering Performance Rules
- Never create components inside components — define at module level
- Never create objects/arrays in JSX —
style={{ color: 'red' }} rerenders always
- Children as props prevent rerender —
<Layout><ExpensiveChild /></Layout>
- Key must be stable and unique — not index, not
Math.random()
- Avoid context value churn — memoize provider value or split contexts
- Profile before optimizing — React DevTools Profiler, not guesswork
Phase 8: Error Handling & Resilience
Error Boundary Architecture
// Three levels of error boundaries:
// 1. App-level (catches everything, shows full-page error)
// 2. Feature-level (isolates feature failures)
// 3. Component-level (for risky widgets — charts, third-party)
// Modern error boundary with react-error-boundary
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
function FeatureErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
return (
<div role="alert" className="rounded-lg border-red-200 bg-red-50 p-4">
<h3>Something went wrong</h3>
<pre className="text-sm text-red-600">{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
}
// Usage:
<ErrorBoundary FallbackComponent={FeatureErrorFallback} onReset={() => queryClient.clear()}>
<DashboardFeature />
</ErrorBoundary>
Error Handling Checklist
Phase 9: Forms & Validation
Form Library Decision
| Library |
Best For |
Bundle |
Renders |
| React Hook Form |
Most forms |
9 KB |
Minimal (uncontrolled) |
| Formik |
Simple forms |
13 KB |
Every keystroke |
| TanStack Form |
Type-safe complex |
5 KB |
Controlled |
| Native |
1-2 field forms |
0 KB |
You control |
Default recommendation: React Hook Form + Zod
Form Pattern
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
role: z.enum(['admin', 'user']),
})
type FormData = z.infer<typeof schema>
export function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', role: 'user' },
})
return (
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...form.register('email')} aria-invalid={!!form.formState.errors.email} />
{form.formState.errors.email && (
<p role="alert">{form.formState.errors.email.message}</p>
)}
{/* ... more fields */}
<button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Signing in...' : 'Sign in'}
</button>
</form>
)
}
Phase 10: Testing Strategy
Test Pyramid for React
| Level |
Tool |
Coverage Target |
What to Test |
| Unit |
Vitest |
80% business logic |
Hooks, utilities, reducers |
| Component |
Testing Library |
Key user flows |
Rendering, interactions, a11y |
| Integration |
Testing Library |
Feature flows |
Multi-component workflows |
| E2E |
Playwright |
Critical paths |
Auth, checkout, core flows |
| Visual |
Chromatic/Percy |
UI components |
Regression detection |
Testing Patterns
// Component test (Testing Library philosophy: test behavior, not implementation)
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
describe('UserCard', () => {
it('calls onEdit when edit button clicked', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
render(<UserCard user={mockUser} onEdit={onEdit} />)
await user.click(screen.getByRole('button', { name: /edit/i }))
expect(onEdit).toHaveBeenCalledWith(mockUser.id)
})
it('does not render edit button when onEdit not provided', () => {
render(<UserCard user={mockUser} />)
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument()
})
})
7 Testing Rules
- Test behavior, not implementation — never test state directly or useEffect
- Use accessible queries —
getByRole > getByTestId > getByText
- User events over fireEvent —
userEvent.click simulates real interaction
- One assertion per concept — not one per test, but focused assertions
- Mock at boundaries — API calls, not internal functions
- No snapshot tests — they break on every change and test nothing meaningful
- Arrange-Act-Assert — clear structure in every test
Phase 11: Accessibility (a11y)
10-Point Accessibility Checklist
- Semantic HTML —
<button> not <div onClick>, <nav> not <div class="nav">
- Keyboard navigation — every interactive element reachable via Tab, operable via Enter/Space
- Focus management — visible focus indicator, logical tab order, focus trap in modals
- Alt text — every
<img> has descriptive alt (or alt="" if decorative)
- Color contrast — 4.5:1 for normal text, 3:1 for large text (WCAG AA)
- ARIA labels —
aria-label for icon-only buttons, aria-describedby for hints
- Live regions —
aria-live="polite" for dynamic content (toasts, form errors)
- Reduced motion — respect
prefers-reduced-motion for animations
- Screen reader testing — test with VoiceOver (Mac) or NVDA (Windows)
- Automated scanning — axe-core in CI (
vitest-axe or @axe-core/playwright)
Phase 12: Production Deployment Checklist
Mandatory (P0)
Recommended (P1)
Recommended Stack (2025+)
| Layer |
Recommendation |
Alternative |
| Framework |
Next.js 15 |
Remix, Vite SPA |
| Language |
TypeScript (strict) |
— |
| Styling |
Tailwind CSS v4 |
CSS Modules |
| Components |
shadcn/ui |
Radix, Headless UI |
| State (server) |
TanStack Query v5 |
SWR |
| State (client) |
Zustand |
Jotai |
| Forms |
React Hook Form + Zod |
TanStack Form |
| Testing |
Vitest + Testing Library |
Jest |
| E2E |
Playwright |
Cypress |
| Linting |
Biome |
ESLint + Prettier |
| Auth |
Auth.js (NextAuth) |
Clerk, Lucia |
| Database |
Drizzle ORM |
Prisma |
| Deployment |
Vercel |
Cloudflare, Fly.io |
| Monitoring |
Sentry |
Datadog |
Quality Scoring (0-100)
| Dimension |
Weight |
What to Score |
| Architecture |
20% |
Structure, separation, patterns |
| Type safety |
15% |
Strict TS, zero any, Zod boundaries |
| Performance |
15% |
Core Web Vitals, bundle size |
| Testing |
15% |
Coverage, quality, pyramid |
| Accessibility |
10% |
WCAG AA, keyboard, screen reader |
| State management |
10% |
Right tool, no prop drilling |
| Error handling |
10% |
Boundaries, user-friendly, monitoring |
| Developer experience |
5% |
Linting, formatting, CI speed |
Grading: 90+ World-class | 75-89 Production-ready | 60-74 Needs work | <60 Tech debt crisis
10 Common Mistakes
| # |
Mistake |
Fix |
| 1 |
useEffect for derived state |
Compute inline or useMemo |
| 2 |
Prop drilling 5+ levels deep |
Context, Zustand, or composition |
| 3 |
Fetching in useEffect |
TanStack Query or framework loaders |
| 4 |
Default exports everywhere |
Named exports for refactoring safety |
| 5 |
Testing implementation details |
Test behavior with Testing Library |
| 6 |
Giant components (500+ lines) |
Extract hooks and sub-components |
| 7 |
No error boundaries |
Add at app, feature, and widget level |
| 8 |
Redux for server state |
TanStack Query for API data |
| 9 |
Ignoring a11y until the end |
Build accessible from day 1 |
| 10 |
No TypeScript strict mode |
Enable strict, fix all errors |
Natural Language Commands
- "Set up a new React project" → Phase 1-2 architecture + structure
- "Review my component" → Phase 3 rules + quality scoring
- "Help me choose state management" → Phase 4 decision tree
- "Optimize performance" → Phase 7 priority stack + profiling
- "Add error handling" → Phase 8 error boundary architecture
- "Build a form" → Phase 9 React Hook Form + Zod pattern
- "Write tests for this component" → Phase 10 testing patterns
- "Check accessibility" → Phase 11 checklist
- "Prepare for production" → Phase 12 deployment checklist
- "Audit my React app" → Full quality scoring across all phases
- "Migrate from class components" → Modern patterns + hooks
- "Upgrade to React 19" → Compiler, Server Components, Actions
⚡ Level Up Your React Development
This skill gives you the methodology. For industry-specific implementation patterns, grab an AfrexAI Context Pack ($47):
- SaaS Context Pack — SaaS-specific React patterns, billing UI, dashboard architecture
- Fintech Context Pack — Financial UI patterns, real-time data, compliance
- Healthcare Context Pack — HIPAA-compliant UI, patient data handling
👉 Browse all 10 packs: https://afrexai-cto.github.io/context-packs/
🔗 More Free Skills by AfrexAI
afrexai-nextjs-production — Next.js production engineering
afrexai-vibe-coding — AI-assisted development methodology
afrexai-technical-seo — SEO for React SPAs and SSR
afrexai-test-automation-engineering — Complete testing strategy
afrexai-ui-design-system — Design system architecture