Professional React Architecture: TypeScript, Vite Tooling, and Advanced Design Patterns
Writing React is one thing; architecting it for scale is another. This guide bridges the gap between coding and engineering. We cover the transition to a strictly typed ecosystem, modern build pipelines using Vite, and the implementation of sophisticated design patterns—including Compound Components, Control Props, and the State Reducer pattern—to build reusable, library-grade UI.
TypeScript with React
React with TypeScript Setup
Create a TypeScript React project using Vite (npm create vite@latest my-app -- --template react-ts) or CRA (npx create-react-app my-app --template typescript); configure tsconfig.json with strict mode and JSX support.
// tsconfig.json (key options) { "compilerOptions": { "target": "ES2020", "lib": ["DOM", "DOM.Iterable", "ES2020"], "jsx": "react-jsx", "strict": true, "module": "ESNext", "moduleResolution": "bundler", "noEmit": true }, "include": ["src"] }
Typing Props
Define component props using interfaces or type aliases, making props explicit, self-documenting, and enabling compile-time validation.
// Interface approach (preferred for public APIs) interface ButtonProps { label: string; onClick: () => void; variant?: 'primary' | 'secondary'; // optional with union disabled?: boolean; } // Destructure with types function Button({ label, onClick, variant = 'primary', disabled = false }: ButtonProps) { return ( <button onClick={onClick} disabled={disabled} className={variant}> {label} </button> ); } // Usage - TypeScript enforces correct props <Button label="Submit" onClick={() => save()} /> <Button label={123} /> // ❌ Error: number not assignable to string
Typing State
Use generic type parameter with useState<T>() to explicitly type state, especially important for complex objects, arrays, or when initial value is null/undefined.
// Inferred types (simple cases) const [count, setCount] = useState(0); // number const [name, setName] = useState(''); // string // Explicit types (complex/nullable) interface User { id: number; name: string; email: string; } const [user, setUser] = useState<User | null>(null); const [users, setUsers] = useState<User[]>([]); // Type guards for nullable state if (user) { console.log(user.name); // TypeScript knows user is User, not null }
Typing Events
React provides generic event types like React.ChangeEvent<T> and React.MouseEvent<T> where T is the element type receiving the event.
// Common event handlers const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { setValue(e.target.value); }; const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); }; const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { console.log(e.currentTarget.name); }; const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') submit(); }; // Inline typing <input onChange={(e: React.ChangeEvent<HTMLInputElement>) => setVal(e.target.value)} />
Typing Refs
Use useRef<T> with the appropriate HTML element type; for DOM refs, initialize with null and TypeScript will require null checks before accessing .current.
// DOM element refs const inputRef = useRef<HTMLInputElement>(null); const divRef = useRef<HTMLDivElement>(null); useEffect(() => { inputRef.current?.focus(); // Optional chaining for null safety }, []); // Mutable value refs (no null) const countRef = useRef<number>(0); countRef.current += 1; // No null check needed // With imperative handle interface ModalHandle { open: () => void; close: () => void; } const modalRef = useRef<ModalHandle>(null); modalRef.current?.open();
Typing Children
Use React.ReactNode for flexible children (strings, elements, arrays, null), or React.ReactElement when you need specifically JSX elements.
// Most flexible - accepts anything renderable interface CardProps { children: React.ReactNode; } // Only JSX elements (no strings/numbers) interface LayoutProps { children: React.ReactElement; } // Function as children (render props) interface DataFetcherProps<T> { children: (data: T, loading: boolean) => React.ReactNode; } // Specific children types interface TabsProps { children: React.ReactElement<TabProps> | React.ReactElement<TabProps>[]; }
Generic Components
Generic components accept type parameters, enabling reusable components that maintain type safety for varying data shapes like lists, selects, or data tables.
// Generic list component interface ListProps<T> { items: T[]; renderItem: (item: T) => React.ReactNode; keyExtractor: (item: T) => string; } function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul> {items.map(item => ( <li key={keyExtractor(item)}>{renderItem(item)}</li> ))} </ul> ); } // Usage - T is inferred as User interface User { id: string; name: string; } <List<User> items={users} renderItem={(user) => <span>{user.name}</span>} // user is typed as User keyExtractor={(user) => user.id} />
Typing Hooks
Custom hooks return types are inferred, but explicit return types improve API clarity; use tuples with as const for useState-like returns.
// Explicit return type for clarity function useToggle(initial: boolean): [boolean, () => void, (v: boolean) => void] { const [value, setValue] = useState(initial); const toggle = useCallback(() => setValue(v => !v), []); const set = useCallback((v: boolean) => setValue(v), []); return [value, toggle, set]; } // Generic custom hook function useFetch<T>(url: string): { data: T | null; loading: boolean; error: Error | null } { const [data, setData] = useState<T | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null); // ... fetch logic return { data, loading, error }; } // Usage const { data } = useFetch<User[]>('/api/users'); // data is User[] | null
Typing Context
Create typed context with explicit types for the context value; use a non-null assertion or custom hook to avoid undefined checks in consumers.
interface AuthContextType { user: User | null; login: (email: string, password: string) => Promise<void>; logout: () => void; } // Option 1: undefined default with custom hook const AuthContext = createContext<AuthContextType | undefined>(undefined); function useAuth(): AuthContextType { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be within AuthProvider'); return context; } // Option 2: Non-null assertion (simpler but less safe) const AuthContext = createContext<AuthContextType>(null!); // Usage in components const { user, login } = useAuth(); // Fully typed, no undefined
React.FC Type (and Why to Avoid)
React.FC<Props> was popular but is now discouraged: it implicitly includes children (fixed in React 18), doesn't support generics well, and provides little benefit over plain function typing.
// ❌ Avoid React.FC const Button: React.FC<ButtonProps> = ({ label }) => { return <button>{label}</button>; }; // ✅ Preferred: Direct function typing function Button({ label }: ButtonProps) { return <button>{label}</button>; } // ✅ Also fine: Arrow with typed props const Button = ({ label }: ButtonProps) => { return <button>{label}</button>; }; // ✅ When you need return type annotation const Button = ({ label }: ButtonProps): React.ReactElement => { return <button>{label}</button>; };
Component Prop Types
Use TypeScript utility types to extract, extend, or transform component props for composition and reusability.
// Extract props from existing component type ButtonProps = React.ComponentProps<typeof Button>; type InputProps = React.ComponentPropsWithoutRef<'input'>; type DivProps = React.ComponentPropsWithRef<'div'>; // Extend HTML element props interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> { label: string; error?: string; } // Omit and extend interface CardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> { title: React.ReactNode; // Override title to accept ReactNode } // Polymorphic "as" prop interface BoxProps<C extends React.ElementType> { as?: C; children: React.ReactNode; } type PolymorphicProps<C extends React.ElementType> = BoxProps<C> & Omit<React.ComponentPropsWithoutRef<C>, keyof BoxProps<C>>;
Typing Forms
Type form state and handlers explicitly, or use libraries like React Hook Form that provide built-in TypeScript support with generic form schemas.
// Manual form typing interface FormData { email: string; password: string; rememberMe: boolean; } const [form, setForm] = useState<FormData>({ email: '', password: '', rememberMe: false, }); const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const { name, value, type, checked } = e.target; setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value, })); }; // React Hook Form (recommended) import { useForm, SubmitHandler } from 'react-hook-form'; const { register, handleSubmit } = useForm<FormData>(); const onSubmit: SubmitHandler<FormData> = data => console.log(data);
Typing Third-Party Libraries
Install @types/* packages for type definitions; for untyped libraries, create declarations in *.d.ts files or use declare module to add types.
// Most libraries: install types package npm install lodash @types/lodash // Check if types exist npx @types/check <package-name> // For untyped libraries: src/types/untyped-lib.d.ts declare module 'untyped-library' { export function doSomething(value: string): number; export interface Options { timeout: number; } } // Extend existing library types declare module 'axios' { export interface AxiosRequestConfig { customProperty?: string; } } // Quick escape hatch (not recommended) const lib = require('untyped-lib') as any;
Build Tools
Create React App
CRA provides a zero-config React setup with Webpack, Babel, ESLint, and Jest pre-configured; great for learning but limited customization without "ejecting" (which is irreversible and discouraged).
# Create new project npx create-react-app my-app npx create-react-app my-app --template typescript # Scripts npm start # Dev server on :3000 npm run build # Production build npm test # Jest in watch mode npm run eject # ⚠️ Expose all config (irreversible) # Project structure my-app/ ├── public/ │ └── index.html ├── src/ │ ├── App.tsx │ └── index.tsx └── package.json
Vite for React
Vite is the modern choice for React: instant dev server startup via native ES modules, lightning-fast HMR, and optimized production builds using Rollup—significantly faster than CRA.
# Create project npm create vite@latest my-app -- --template react-ts # Scripts npm run dev # Dev server (usually <1s startup) npm run build # Production build npm run preview # Preview production build # vite.config.ts import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { port: 3000 }, build: { sourcemap: true }, resolve: { alias: { '@': '/src' } } });
Webpack Configuration
Webpack bundles JavaScript modules and assets; while CRA/Vite abstract it away, understanding Webpack helps when customization is needed via craco or custom setups.
// webpack.config.js (simplified React config) const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { entry: './src/index.tsx', output: { path: __dirname + '/dist', filename: '[name].[contenthash].js', }, module: { rules: [ { test: /\.(ts|tsx)$/, use: 'ts-loader', exclude: /node_modules/ }, { test: /\.css$/, use: ['style-loader', 'css-loader'] }, { test: /\.(png|svg)$/, type: 'asset/resource' }, ], }, resolve: { extensions: ['.tsx', '.ts', '.js'] }, plugins: [ new HtmlWebpackPlugin({ template: './public/index.html' }), ], devServer: { hot: true, port: 3000 }, };
Babel Configuration
Babel transpiles modern JavaScript/JSX to browser-compatible code; for TypeScript projects, you can use Babel for transpilation (faster) and tsc only for type checking.
// babel.config.js module.exports = { presets: [ ['@babel/preset-env', { targets: { browsers: '>0.25%' } }], ['@babel/preset-react', { runtime: 'automatic' }], // React 17+ JSX '@babel/preset-typescript', ], plugins: [ '@babel/plugin-transform-runtime', // Optional: styled-components, emotion, etc. ], }; // package.json { "browserslist": { "production": [">0.2%", "not dead"], "development": ["last 1 chrome version"] } }
ESLint for React
ESLint catches bugs and enforces code style; use eslint-plugin-react and eslint-plugin-react-hooks to catch React-specific issues like missing dependencies in useEffect.
// .eslintrc.js module.exports = { parser: '@typescript-eslint/parser', extends: [ 'eslint:recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', 'plugin:@typescript-eslint/recommended', ], plugins: ['react', 'react-hooks', '@typescript-eslint'], rules: { 'react-hooks/rules-of-hooks': 'error', // Checks hook rules 'react-hooks/exhaustive-deps': 'warn', // Checks effect deps 'react/react-in-jsx-scope': 'off', // Not needed React 17+ 'react/prop-types': 'off', // Using TypeScript }, settings: { react: { version: 'detect' } }, };
Prettier
Prettier is an opinionated code formatter that handles style automatically, eliminating debates; integrate with ESLint using eslint-config-prettier to avoid conflicts.
// .prettierrc { "semi": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5", "printWidth": 100, "bracketSpacing": true, "jsxSingleQuote": false } // .eslintrc.js - disable conflicting rules extends: ['eslint:recommended', 'prettier'] // prettier must be last // package.json scripts { "format": "prettier --write 'src/**/*.{ts,tsx}'", "format:check": "prettier --check 'src/**/*.{ts,tsx}'" }
Environment Variables
Environment variables configure app behavior per environment; in React, they're embedded at build time (not runtime) and must be prefixed appropriately for security.
# CRA: must prefix with REACT_APP_ REACT_APP_API_URL=https://api.example.com REACT_APP_FEATURE_FLAG=true # Vite: must prefix with VITE_ VITE_API_URL=https://api.example.com VITE_FEATURE_FLAG=true
// CRA usage const apiUrl = process.env.REACT_APP_API_URL; // Vite usage const apiUrl = import.meta.env.VITE_API_URL; // ⚠️ Security: All env vars are bundled into client code! // Never put secrets in REACT_APP_ or VITE_ variables
.env Files
Create multiple .env files for different environments; they're loaded based on NODE_ENV and the build/dev command.
# File loading priority (CRA) .env # Always loaded .env.local # Local overrides (gitignored) .env.development # npm start .env.development.local # npm start (gitignored) .env.production # npm run build .env.production.local # npm run build (gitignored) .env.test # npm test # Vite modes .env # All modes .env.local # All modes (gitignored) .env.[mode] # e.g., .env.staging .env.[mode].local # e.g., .env.staging.local # Usage: vite build --mode staging
Build Optimization
Optimize production builds through code splitting, tree shaking, lazy loading, and bundle analysis to reduce initial load time.
// Code splitting with lazy loading const Dashboard = lazy(() => import('./pages/Dashboard')); const Settings = lazy(() => import('./pages/Settings')); // Dynamic imports for heavy libraries const handleExport = async () => { const { exportToPDF } = await import('./utils/pdfExport'); exportToPDF(data); }; // Bundle analysis npm install -D webpack-bundle-analyzer // CRA npx vite-bundle-visualizer // Vite // vite.config.ts optimizations build: { rollupOptions: { output: { manualChunks: { vendor: ['react', 'react-dom'], charts: ['recharts'], } } } }
Production Builds
Production builds minify code, optimize assets, generate hashed filenames for caching, and remove development-only code like PropTypes and debug warnings.
# Build commands npm run build # Creates /build (CRA) or /dist (Vite) # Production build output dist/ ├── index.html ├── assets/ │ ├── index-a1b2c3d4.js # Hashed for cache busting │ ├── index-x9y8z7w6.css │ └── logo-abc123.png └── favicon.ico # Optimizations applied: # ✓ Minification (Terser) # ✓ Tree shaking (dead code removal) # ✓ CSS extraction and minification # ✓ Asset hashing # ✓ Gzip/Brotli compression (usually server-side) # Serve locally for testing npx serve -s dist
Source Maps
Source maps map minified production code back to original source for debugging; enable them carefully as they expose source code.
// Vite config export default defineConfig({ build: { sourcemap: true, // Generates .map files sourcemap: 'hidden', // Maps for error tracking only (no browser access) } }); // CRA: set in .env GENERATE_SOURCEMAP=true // Enable GENERATE_SOURCEMAP=false // Disable for security // Webpack devtool: 'source-map', // Full source maps devtool: 'hidden-source-map', // For error tracking services devtool: 'nosources-source-map', // Stack traces only, no source // Upload to error tracking (Sentry, etc.) // Keep .map files private, don't deploy to CDN
Advanced Patterns
Compound Components
Compound components share implicit state through Context, allowing flexible, declarative APIs where parent and children work together (like HTML <select> and <option>).
// Implementation const TabsContext = createContext<{ activeTab: string; setActiveTab: (t: string) => void } | null>(null); function Tabs({ children, defaultTab }: { children: ReactNode; defaultTab: string }) { const [activeTab, setActiveTab] = useState(defaultTab); return ( <TabsContext.Provider value={{ activeTab, setActiveTab }}> <div className="tabs">{children}</div> </TabsContext.Provider> ); } Tabs.Tab = function Tab({ id, children }: { id: string; children: ReactNode }) { const { activeTab, setActiveTab } = useContext(TabsContext)!; return <button onClick={() => setActiveTab(id)} data-active={activeTab === id}>{children}</button>; }; Tabs.Panel = function Panel({ id, children }: { id: string; children: ReactNode }) { const { activeTab } = useContext(TabsContext)!; return activeTab === id ? <div>{children}</div> : null; }; // Usage - Clean, declarative API <Tabs defaultTab="a"> <Tabs.Tab id="a">Tab A</Tabs.Tab> <Tabs.Tab id="b">Tab B</Tabs.Tab> <Tabs.Panel id="a">Content A</Tabs.Panel> <Tabs.Panel id="b">Content B</Tabs.Panel> </Tabs>
Controlled vs Uncontrolled
Controlled components have their state managed by React through props and callbacks; uncontrolled components manage their own internal state, accessed via refs when needed.
// Controlled: React owns the state function ControlledInput() { const [value, setValue] = useState(''); return ( <input value={value} onChange={(e) => setValue(e.target.value)} /> ); } // Uncontrolled: DOM owns the state function UncontrolledInput() { const inputRef = useRef<HTMLInputElement>(null); const handleSubmit = () => console.log(inputRef.current?.value); return <input ref={inputRef} defaultValue="initial" />; } // Hybrid: Support both patterns interface InputProps { value?: string; // Controlled defaultValue?: string; // Uncontrolled onChange?: (value: string) => void; } function FlexibleInput({ value, defaultValue, onChange }: InputProps) { const isControlled = value !== undefined; const [internalValue, setInternalValue] = useState(defaultValue ?? ''); const currentValue = isControlled ? value : internalValue; // ... }
State Reducer Pattern
The state reducer pattern lets consumers customize state updates by passing a reducer function, providing maximum flexibility while maintaining internal logic control.
// Component with customizable state logic type ToggleState = { on: boolean }; type ToggleAction = { type: 'toggle' } | { type: 'reset' }; function useToggle( reducer: (state: ToggleState, action: ToggleAction) => ToggleState = defaultReducer ) { const [state, dispatch] = useReducer(reducer, { on: false }); const toggle = () => dispatch({ type: 'toggle' }); const reset = () => dispatch({ type: 'reset' }); return { ...state, toggle, reset }; } function defaultReducer(state: ToggleState, action: ToggleAction): ToggleState { switch (action.type) { case 'toggle': return { on: !state.on }; case 'reset': return { on: false }; } } // Usage: Custom behavior - limit toggle count const { on, toggle } = useToggle((state, action) => { if (action.type === 'toggle' && toggleCount >= 3) { return state; // Prevent toggle after 3 times } return defaultReducer(state, action); });
Provider Pattern
The Provider pattern wraps context providers into a reusable component, encapsulating state logic and providing a clean API through a custom hook.
// Auth provider pattern interface AuthContextType { user: User | null; login: (credentials: Credentials) => Promise<void>; logout: () => void; isLoading: boolean; } const AuthContext = createContext<AuthContextType | null>(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState<User | null>(null); const [isLoading, setIsLoading] = useState(true); const login = async (creds: Credentials) => { /* ... */ }; const logout = () => { /* ... */ }; useEffect(() => { /* Check existing session */ }, []); return ( <AuthContext.Provider value={{ user, login, logout, isLoading }}> {children} </AuthContext.Provider> ); } export function useAuth() { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be within AuthProvider'); return context; }
HOC Pattern
Higher-Order Components wrap components to inject props, add behavior, or enhance functionality; largely replaced by hooks but still useful for cross-cutting concerns.
// HOC: Add loading state to any component interface WithLoadingProps { isLoading: boolean; } function withLoading<P extends object>(WrappedComponent: React.ComponentType<P>) { return function WithLoadingComponent({ isLoading, ...props }: P & WithLoadingProps) { if (isLoading) return <Spinner />; return <WrappedComponent {...props as P} />; }; } // Usage const UserListWithLoading = withLoading(UserList); <UserListWithLoading isLoading={loading} users={users} /> // HOC: Add authentication check function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) { return function WithAuthComponent(props: P) { const { user, isLoading } = useAuth(); if (isLoading) return <Spinner />; if (!user) return <Navigate to="/login" />; return <WrappedComponent {...props} />; }; }
Render Props Pattern
Render props pass a function as children (or a prop) that receives data and returns JSX, enabling component logic reuse before hooks existed; still useful for complex cases.
// Render props pattern interface MouseTrackerProps { children: (position: { x: number; y: number }) => ReactNode; } function MouseTracker({ children }: MouseTrackerProps) { const [position, setPosition] = useState({ x: 0, y: 0 }); useEffect(() => { const handler = (e: MouseEvent) => setPosition({ x: e.clientX, y: e.clientY }); window.addEventListener('mousemove', handler); return () => window.removeEventListener('mousemove', handler); }, []); return <>{children(position)}</>; } // Usage <MouseTracker> {({ x, y }) => ( <div>Mouse: {x}, {y}</div> )} </MouseTracker> // Modern equivalent: custom hook function useMousePosition() { /* same logic, return position */ }
Container/Presentational Pattern
Separate components into containers (logic, data fetching, state) and presentational components (pure UI, receive data via props); with hooks, this is less rigid but still valuable for clarity.
// Presentational: Pure UI, easy to test and reuse interface UserCardProps { name: string; avatar: string; onClick: () => void; } function UserCard({ name, avatar, onClick }: UserCardProps) { return ( <div className="user-card" onClick={onClick}> <img src={avatar} alt={name} /> <h3>{name}</h3> </div> ); } // Container: Handles data and logic function UserCardContainer({ userId }: { userId: string }) { const { data: user, isLoading } = useQuery(['user', userId], fetchUser); const navigate = useNavigate(); if (isLoading) return <Skeleton />; return ( <UserCard name={user.name} avatar={user.avatar} onClick={() => navigate(`/users/${userId}`)} /> ); }
Atomic Design
Atomic Design organizes components into five levels: atoms (buttons, inputs), molecules (form fields), organisms (forms, cards), templates (layouts), and pages—promoting reusability and consistency.
src/components/
├── atoms/ # Basic building blocks
│ ├── Button/
│ ├── Input/
│ ├── Label/
│ └── Icon/
├── molecules/ # Groups of atoms
│ ├── FormField/ # Label + Input + Error
│ ├── SearchBox/ # Input + Button
│ └── NavItem/ # Icon + Label
├── organisms/ # Complex UI sections
│ ├── Header/
│ ├── LoginForm/
│ └── ProductCard/
├── templates/ # Page layouts
│ ├── DashboardLayout/
│ └── AuthLayout/
└── pages/ # Complete pages
├── HomePage/
└── LoginPage/
Feature-First Organization
Organize code by feature/domain rather than type (components, hooks, utils), keeping related code together for better cohesion, easier navigation, and independent deployability.
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── SignupForm.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── api/
│ │ │ └── authApi.ts
│ │ ├── types.ts
│ │ └── index.ts # Public exports
│ ├── products/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── index.ts
│ └── cart/
├── shared/ # Cross-feature code
│ ├── components/
│ ├── hooks/
│ └── utils/
├── app/ # App-level setup
│ ├── routes.tsx
│ └── store.ts
└── main.tsx
Hooks Composition
Compose multiple hooks together to build complex behavior from simple, reusable pieces; each hook handles one concern, combined as needed.
// Simple hooks function useToggle(initial = false) { const [on, setOn] = useState(initial); const toggle = useCallback(() => setOn(v => !v), []); return [on, toggle] as const; } function useLocalStorage<T>(key: string, initial: T) { const [value, setValue] = useState<T>(() => { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : initial; }); useEffect(() => localStorage.setItem(key, JSON.stringify(value)), [key, value]); return [value, setValue] as const; } // Composed hook function useDarkMode() { const [enabled, setEnabled] = useLocalStorage('darkMode', false); useEffect(() => { document.body.classList.toggle('dark', enabled); }, [enabled]); return [enabled, () => setEnabled(!enabled)] as const; }
Custom Hook Patterns
Well-designed custom hooks follow patterns: return objects for named access, provide status flags, handle cleanup, and expose both data and actions.
// Pattern: Return object with clear interface function useAsync<T>(asyncFn: () => Promise<T>, deps: unknown[] = []) { const [state, setState] = useState<{ data: T | null; error: Error | null; status: 'idle' | 'loading' | 'success' | 'error'; }>({ data: null, error: null, status: 'idle' }); const execute = useCallback(async () => { setState({ data: null, error: null, status: 'loading' }); try { const data = await asyncFn(); setState({ data, error: null, status: 'success' }); } catch (error) { setState({ data: null, error: error as Error, status: 'error' }); } }, deps); useEffect(() => { execute(); }, [execute]); return { ...state, isLoading: state.status === 'loading', isError: state.status === 'error', isSuccess: state.status === 'success', refetch: execute, }; }
Dependency Injection
Pass dependencies (API clients, services, configs) through props or context rather than importing directly, enabling easier testing, mocking, and configuration.
// Create injectable services context interface Services { api: ApiClient; analytics: AnalyticsService; storage: StorageService; } const ServicesContext = createContext<Services | null>(null); export function ServicesProvider({ children, services }: { children: ReactNode; services: Services; }) { return ( <ServicesContext.Provider value={services}> {children} </ServicesContext.Provider> ); } export function useServices() { const services = useContext(ServicesContext); if (!services) throw new Error('Missing ServicesProvider'); return services; } // App setup <ServicesProvider services={{ api: prodApi, analytics: prodAnalytics, storage: localStorage }}> <App /> </ServicesProvider> // Test setup - inject mocks <ServicesProvider services={{ api: mockApi, analytics: mockAnalytics, storage: mockStorage }}> <ComponentUnderTest /> </ServicesProvider>