How would you design a reusable, accessible component library? What principles would you follow?
Answer:
Designing a reusable, accessible component library requires careful consideration of multiple aspects:
5.1.1. Core Design Principles
-
Single Responsibility Principle
- Each component should have one clear purpose
- Avoid components that try to do too many things
- Example: Separate
ButtonfromButtonGroup
-
Composition over Configuration
- Prefer composition patterns over complex prop interfaces
- Use render props, children, or compound components
// Good: Composable <Card> <Card.Header>Title</Card.Header> <Card.Body>Content</Card.Body> <Card.Footer>Actions</Card.Footer> </Card> // Avoid: Over-configured <Card hasHeader={true} headerText="Title" hasFooter={true} footerContent="Actions" /> -
Consistent API Design
- Standardize prop naming conventions
- Use consistent patterns across components
- Example:
size,variant,disabledprops across all interactive components
5.1.2. Accessibility (a11y) Principles
-
Semantic HTML
// Good: Semantic button <button type="button" aria-label="Close dialog" onClick={onClose} > <CloseIcon aria-hidden="true" /> </button> -
Keyboard Navigation
- Ensure all interactive elements are keyboard accessible
- Implement proper focus management
- Use appropriate ARIA attributes
-
Screen Reader Support
const Modal = ({ isOpen, onClose, children }) => { const modalRef = useRef<HTMLDivElement>(null); useEffect(() => { if (isOpen && modalRef.current) { modalRef.current.focus(); } }, [isOpen]); return ( <div role="dialog" aria-modal="true" aria-labelledby="modal-title" ref={modalRef} tabIndex={-1} > {children} </div> ); };
5.1.3. TypeScript Integration
-
Strong Typing
interface ButtonProps { variant: 'primary' | 'secondary' | 'danger'; size: 'small' | 'medium' | 'large'; disabled?: boolean; loading?: boolean; children: React.ReactNode; onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void; } const Button: React.FC<ButtonProps> = ({ variant, size, disabled = false, loading = false, children, onClick, ...props }) => { // Implementation }; -
Generic Components
interface ListProps<T> { items: T[]; renderItem: (item: T, index: number) => React.ReactNode; keyExtractor: (item: T) => string | number; } function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) { return ( <ul> {items.map((item, index) => ( <li key={keyExtractor(item)}> {renderItem(item, index)} </li> ))} </ul> ); }
5.1.4. Styling Strategy
-
CSS-in-JS with Theme Support
const StyledButton = styled.button<ButtonProps>` padding: ${({ size, theme }) => theme.spacing[size]}; background-color: ${({ variant, theme }) => theme.colors[variant]}; border: none; border-radius: ${({ theme }) => theme.borderRadius.medium}; &:disabled { opacity: 0.6; cursor: not-allowed; } `; -
CSS Custom Properties for Theming
:root { --color-primary: #007bff; --color-secondary: #6c757d; --spacing-small: 8px; --spacing-medium: 16px; }
5.1.5. Documentation & Testing
-
Storybook Integration
- Document all component variants
- Provide interactive examples
- Include accessibility testing
-
Comprehensive Testing
describe('Button Component', () => { it('renders with correct accessibility attributes', () => { render(<Button aria-label="Test button">Click me</Button>); const button = screen.getByRole('button', { name: 'Test button' }); expect(button).toBeInTheDocument(); }); it('handles keyboard navigation', () => { const handleClick = jest.fn(); render(<Button onClick={handleClick}>Click me</Button>); const button = screen.getByRole('button'); fireEvent.keyDown(button, { key: 'Enter' }); expect(handleClick).toHaveBeenCalled(); }); });
5.1.6. Performance Considerations
-
Memoization
const ExpensiveComponent = React.memo<Props>(({ data, onAction }) => { const processedData = useMemo(() => { return data.map(item => expensiveTransformation(item)); }, [data]); return <div>{/* Render processed data */}</div>; }); -
Lazy Loading
const LazyModal = React.lazy(() => import('./Modal')); const App = () => ( <Suspense fallback={<Spinner />}> <LazyModal /> </Suspense> );
This approach ensures components are maintainable, accessible, performant, and provide a great developer experience.
Explain different strategies for code splitting in React. How would you implement route-based code splitting?
Answer:
Code splitting is a technique to split your code into smaller chunks that can be loaded on demand, improving initial load performance.
5.2.1. Code Splitting Strategies
-
Route-Based Code Splitting
import { lazy, Suspense } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; // Lazy load route components const Home = lazy(() => import('./pages/Home')); const About = lazy(() => import('./pages/About')); const Contact = lazy(() => import('./pages/Contact')); const Dashboard = lazy(() => import('./pages/Dashboard')); const App = () => ( <BrowserRouter> <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/" element={<Home />} /> <Route path="/about" element={<About />} /> <Route path="/contact" element={<Contact />} /> <Route path="/dashboard" element={<Dashboard />} /> </Routes> </Suspense> </BrowserRouter> ); -
Component-Based Code Splitting
import { lazy, Suspense, useState } from 'react'; const HeavyChart = lazy(() => import('./HeavyChart')); const DataTable = lazy(() => import('./DataTable')); const Dashboard = () => { const [showChart, setShowChart] = useState(false); const [showTable, setShowTable] = useState(false); return ( <div> <button onClick={() => setShowChart(true)}> Load Chart </button> <button onClick={() => setShowTable(true)}> Load Table </button> {showChart && ( <Suspense fallback={<div>Loading chart...</div>}> <HeavyChart /> </Suspense> )} {showTable && ( <Suspense fallback={<div>Loading table...</div>}> <DataTable /> </Suspense> )} </div> ); }; -
Library-Based Code Splitting
// Split large libraries const loadMoment = () => import('moment'); const loadLodash = () => import('lodash'); const DateFormatter = () => { const [moment, setMoment] = useState(null); useEffect(() => { loadMoment().then(({ default: moment }) => { setMoment(moment); }); }, []); if (!moment) return <div>Loading date formatter...</div>; return <div>{moment().format('YYYY-MM-DD')}</div>; };
5.2.2. Advanced Code Splitting Patterns
-
Preloading with Intersection Observer
const usePreloadOnIntersection = (importFn: () => Promise<any>) => { const [isLoaded, setIsLoaded] = useState(false); const ref = useRef<HTMLDivElement>(null); useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !isLoaded) { importFn().then(() => setIsLoaded(true)); observer.disconnect(); } }, { threshold: 0.1 } ); if (ref.current) { observer.observe(ref.current); } return () => observer.disconnect(); }, [importFn, isLoaded]); return { ref, isLoaded }; }; const LazyComponent = () => { const { ref, isLoaded } = usePreloadOnIntersection( () => import('./HeavyComponent') ); return ( <div ref={ref}> {isLoaded ? ( <Suspense fallback={<div>Loading...</div>}> <HeavyComponent /> </Suspense> ) : ( <div>Component will load when visible</div> )} </div> ); }; -
Dynamic Imports with Error Boundaries
class ChunkLoadErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, retryCount: 0 }; } static getDerivedStateFromError(error) { if (error.name === 'ChunkLoadError') { return { hasError: true }; } return null; } componentDidCatch(error, errorInfo) { if (error.name === 'ChunkLoadError') { console.error('Chunk load failed:', error); } } retry = () => { this.setState(prevState => ({ hasError: false, retryCount: prevState.retryCount + 1 })); }; render() { if (this.state.hasError) { return ( <div> <h2>Failed to load component</h2> <button onClick={this.retry}> Retry (Attempt {this.state.retryCount + 1}) </button> </div> ); } return this.props.children; } } const App = () => ( <ChunkLoadErrorBoundary> <Suspense fallback={<div>Loading...</div>}> <Routes> <Route path="/dashboard" element={<Dashboard />} /> </Routes> </Suspense> </ChunkLoadErrorBoundary> ); -
Conditional Code Splitting
const ConditionalComponent = ({ userRole }: { userRole: string }) => { const [AdminPanel, setAdminPanel] = useState<React.ComponentType | null>(null); useEffect(() => { if (userRole === 'admin') { import('./AdminPanel').then(({ default: AdminPanelComponent }) => { setAdminPanel(() => AdminPanelComponent); }); } }, [userRole]); if (userRole === 'admin' && AdminPanel) { return ( <Suspense fallback={<div>Loading admin panel...</div>}> <AdminPanel /> </Suspense> ); } return <div>Regular user content</div>; };
5.2.3. Webpack Configuration for Code Splitting
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
enforce: true,
},
},
},
},
};
5.2.4. Performance Monitoring
const useChunkLoadTime = () => {
const [loadTimes, setLoadTimes] = useState<Record<string, number>>({});
const trackChunkLoad = (chunkName: string) => {
const startTime = performance.now();
return () => {
const endTime = performance.now();
const loadTime = endTime - startTime;
setLoadTimes(prev => ({
...prev,
[chunkName]: loadTime
}));
// Send to analytics
analytics.track('chunk_loaded', {
chunk: chunkName,
loadTime,
timestamp: Date.now()
});
};
};
return { loadTimes, trackChunkLoad };
};
// Usage
const Dashboard = () => {
const { trackChunkLoad } = useChunkLoadTime();
useEffect(() => {
const endTracking = trackChunkLoad('dashboard');
return endTracking;
}, [trackChunkLoad]);
return <div>Dashboard content</div>;
};
5.2.5. Best Practices
-
Bundle Analysis
# Analyze bundle size npm install --save-dev webpack-bundle-analyzer npx webpack-bundle-analyzer build/static/js/*.js -
Loading States
- Always provide meaningful loading states
- Consider skeleton screens for better UX
- Implement retry mechanisms for failed chunks
-
Preloading Strategy
- Preload critical routes on user interaction
- Use
<link rel="prefetch">for likely next pages - Implement intelligent preloading based on user behavior
-
Error Handling
- Wrap lazy components in error boundaries
- Provide fallback UI for chunk load failures
- Implement retry mechanisms
This comprehensive approach ensures optimal performance while maintaining a great user experience.
What's your approach to testing React applications? How do you balance unit, integration, and e2e tests?
Answer:
A comprehensive testing strategy for React applications follows the testing pyramid principle, balancing different types of tests for optimal coverage and maintainability.
5.3.1. Testing Pyramid Structure
- Unit Tests (70%) - Fast, isolated tests for individual functions/components
- Integration Tests (20%) - Test component interactions and data flow
- End-to-End Tests (10%) - Full user journey testing
5.3.2. Unit Testing
Component Testing with React Testing Library
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button Component', () => {
it('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('handles click events', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('shows loading state', () => {
render(<Button loading>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
});
Custom Hook Testing
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter Hook', () => {
it('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increments counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets counter', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(0);
});
});
Utility Function Testing
import { formatCurrency, validateEmail } from './utils';
describe('Utility Functions', () => {
describe('formatCurrency', () => {
it('formats positive numbers correctly', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
});
it('handles zero', () => {
expect(formatCurrency(0)).toBe('$0.00');
});
it('handles negative numbers', () => {
expect(formatCurrency(-100)).toBe('-$100.00');
});
});
describe('validateEmail', () => {
it('validates correct email formats', () => {
expect(validateEmail('test@example.com')).toBe(true);
expect(validateEmail('user.name@domain.co.uk')).toBe(true);
});
it('rejects invalid email formats', () => {
expect(validateEmail('invalid-email')).toBe(false);
expect(validateEmail('@domain.com')).toBe(false);
expect(validateEmail('user@')).toBe(false);
});
});
});
5.3.3. Integration Testing
Component Integration with Context
import { render, screen, waitFor } from '@testing-library/react';
import { ThemeProvider } from './ThemeProvider';
import { ThemedButton } from './ThemedButton';
const renderWithTheme = (component: React.ReactElement) => {
return render(
<ThemeProvider theme="dark">
{component}
</ThemeProvider>
);
};
describe('ThemedButton Integration', () => {
it('applies theme styles correctly', () => {
renderWithTheme(<ThemedButton>Click me</ThemedButton>);
const button = screen.getByRole('button');
expect(button).toHaveClass('dark-theme');
});
});
API Integration Testing
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UserList } from './UserList';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' }
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList Integration', () => {
it('fetches and displays users', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
it('handles API errors gracefully', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error loading users/i)).toBeInTheDocument();
});
});
});
Form Integration Testing
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm Integration', () => {
it('submits form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.type(screen.getByLabelText(/message/i), 'Hello world');
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello world'
});
});
});
it('shows validation errors', async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={jest.fn()} />);
await user.click(screen.getByRole('button', { name: /submit/i }));
await waitFor(() => {
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});
});
});
5.3.4. End-to-End Testing
Playwright E2E Tests
import { test, expect } from '@playwright/test';
test.describe('User Authentication Flow', () => {
test('user can login and access dashboard', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
});
test('user can logout', async ({ page }) => {
// Login first
await page.goto('/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
// Logout
await page.click('[data-testid="user-menu"]');
await page.click('[data-testid="logout-button"]');
await expect(page).toHaveURL('/login');
});
});
test.describe('Shopping Cart Flow', () => {
test('user can add items to cart and checkout', async ({ page }) => {
await page.goto('/products');
// Add first product
await page.click('[data-testid="product-1"] [data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
// Add second product
await page.click('[data-testid="product-2"] [data-testid="add-to-cart"]');
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('2');
// Go to cart
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL('/cart');
// Proceed to checkout
await page.click('[data-testid="checkout-button"]');
await expect(page).toHaveURL('/checkout');
});
});
5.3.5. Testing Configuration
Jest Configuration
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
moduleNameMapping: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/setupTests.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
Test Setup
// src/setupTests.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';
// Establish API mocking before all tests
beforeAll(() => server.listen());
// Reset any request handlers that we may add during the tests
afterEach(() => server.resetHandlers());
// Clean up after the tests are finished
afterAll(() => server.close());
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
};
5.3.6. Testing Best Practices
-
Test Behavior, Not Implementation
// Good: Tests user behavior it('shows error message when form is invalid', () => { render(<ContactForm />); fireEvent.click(screen.getByRole('button')); expect(screen.getByText(/please fill all fields/i)).toBeInTheDocument(); }); // Avoid: Tests implementation details it('calls validateForm when submit button is clicked', () => { const validateForm = jest.fn(); render(<ContactForm validateForm={validateForm} />); fireEvent.click(screen.getByRole('button')); expect(validateForm).toHaveBeenCalled(); }); -
Use Data Test IDs Sparingly
// Use semantic queries first screen.getByRole('button', { name: /submit/i }); screen.getByLabelText(/email address/i); // Use data-testid only when necessary screen.getByTestId('complex-component'); -
Mock External Dependencies
// Mock API calls jest.mock('./api', () => ({ fetchUsers: jest.fn(() => Promise.resolve(mockUsers)),
}));
// Mock browser APIs Object.defineProperty(window, 'localStorage', { value: { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), }, });
4. **Test Accessibility**
```tsx
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('should not have accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
5.3.7. Continuous Integration
GitHub Actions Workflow
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
This comprehensive testing strategy ensures code quality, catches regressions early, and provides confidence when deploying changes to production.
How would you implement authentication in a React SPA? Discuss token management, refresh strategies, and protected routes.
Answer:
Implementing authentication in a React SPA requires careful consideration of security, user experience, and token management. Here's a comprehensive approach:
5.4.1. Authentication Architecture
Auth Context and Provider
interface User {
id: string;
email: string;
name: string;
role: 'user' | 'admin';
permissions: string[];
}
interface AuthState {
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
interface AuthContextType extends AuthState {
login: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshAuthToken: () => Promise<void>;
hasPermission: (permission: string) => boolean;
hasRole: (role: string) => boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, setState] = useState<AuthState>({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: true,
error: null,
});
// Initialize auth state from localStorage
useEffect(() => {
const initializeAuth = async () => {
try {
const token = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
if (token && refreshToken) {
// Verify token and get user data
const user = await verifyToken(token);
setState(prev => ({
...prev,
user,
token,
refreshToken,
isAuthenticated: true,
isLoading: false,
}));
} else {
setState(prev => ({ ...prev, isLoading: false }));
}
} catch (error) {
// Clear invalid tokens
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setState(prev => ({ ...prev, isLoading: false }));
}
};
initializeAuth();
}, []);
const login = async (email: string, password: string) => {
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Invalid credentials');
}
const { user, accessToken, refreshToken } = await response.json();
// Store tokens securely
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setState({
user,
token: accessToken,
refreshToken,
isAuthenticated: true,
isLoading: false,
error: null,
});
} catch (error) {
setState(prev => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Login failed',
}));
throw error;
}
};
const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setState({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
};
const refreshAuthToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
logout();
return;
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const { accessToken, refreshToken: newRefreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
setState(prev => ({
...prev,
token: accessToken,
refreshToken: newRefreshToken,
}));
} catch (error) {
logout();
throw error;
}
};
const hasPermission = (permission: string): boolean => {
return state.user?.permissions.includes(permission) ?? false;
};
const hasRole = (role: string): boolean => {
return state.user?.role === role;
};
return (
<AuthContext.Provider
value={{
...state,
login,
logout,
refreshAuthToken,
hasPermission,
hasRole,
}}
>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
5.4.2. Token Management
HTTP Client with Automatic Token Refresh
class ApiClient {
private baseURL: string;
private refreshPromise: Promise<string> | null = null;
constructor(baseURL: string) {
this.baseURL = baseURL;
}
private async makeRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = localStorage.getItem('accessToken');
const config: RequestInit = {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
};
let response = await fetch(`${this.baseURL}${endpoint}`, config);
// Handle token expiration
if (response.status === 401 && token) {
try {
const newToken = await this.refreshToken();
config.headers = {
...config.headers,
Authorization: `Bearer ${newToken}`,
};
response = await fetch(`${this.baseURL}${endpoint}`, config);
} catch (error) {
// Redirect to login
window.location.href = '/login';
throw error;
}
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
private async refreshToken(): Promise<string> {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.performTokenRefresh();
try {
const newToken = await this.refreshPromise;
return newToken;
} finally {
this.refreshPromise = null;
}
}
private async performTokenRefresh(): Promise<string> {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await fetch(`${this.baseURL}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const { accessToken, refreshToken: newRefreshToken } = await response.json();
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
return accessToken;
}
// Public methods
async get<T>(endpoint: string): Promise<T> {
return this.makeRequest<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data: any): Promise<T> {
return this.makeRequest<T>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
async put<T>(endpoint: string, data: any): Promise<T> {
return this.makeRequest<T>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.makeRequest<T>(endpoint, { method: 'DELETE' });
}
}
export const apiClient = new ApiClient(process.env.REACT_APP_API_URL || '');
5.4.3. Protected Routes
Route Protection Components
interface ProtectedRouteProps {
children: React.ReactNode;
requiredPermission?: string;
requiredRole?: string;
fallback?: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requiredPermission,
requiredRole,
fallback = <Navigate to="/login" replace />,
}) => {
const { isAuthenticated, user, isLoading } = useAuth();
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated || !user) {
return <>{fallback}</>;
}
if (requiredRole && !user.role.includes(requiredRole)) {
return <Navigate to="/unauthorized" replace />;
}
if (requiredPermission && !user.permissions.includes(requiredPermission)) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
};
// Usage in routing
const AppRoutes = () => {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute requiredRole="admin">
<AdminPanel />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requiredPermission="manage_settings">
<SettingsPage />
</ProtectedRoute>
}
/>
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
);
};
5.4.4. Advanced Security Features
Session Management
const useSessionManagement = () => {
const { logout } = useAuth();
const [lastActivity, setLastActivity] = useState(Date.now());
const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
useEffect(() => {
const updateActivity = () => setLastActivity(Date.now());
// Track user activity
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
events.forEach(event => {
document.addEventListener(event, updateActivity, true);
});
// Check for session timeout
const checkSession = setInterval(() => {
if (Date.now() - lastActivity > SESSION_TIMEOUT) {
logout();
clearInterval(checkSession);
}
}, 60000); // Check every minute
return () => {
events.forEach(event => {
document.removeEventListener(event, updateActivity, true);
});
clearInterval(checkSession);
};
}, [lastActivity, logout]);
return { lastActivity };
};
Multi-Factor Authentication
interface MFAState {
isEnabled: boolean;
backupCodes: string[];
qrCode: string;
}
const useMFA = () => {
const [mfaState, setMfaState] = useState<MFAState | null>(null);
const { user } = useAuth();
const enableMFA = async () => {
try {
const response = await apiClient.post('/auth/mfa/setup', {});
setMfaState(response);
} catch (error) {
console.error('Failed to setup MFA:', error);
}
};
const verifyMFA = async (token: string) => {
try {
await apiClient.post('/auth/mfa/verify', { token });
return true;
} catch (error) {
return false;
}
};
const disableMFA = async (password: string) => {
try {
await apiClient.post('/auth/mfa/disable', { password });
setMfaState(null);
} catch (error) {
console.error('Failed to disable MFA:', error);
}
};
return {
mfaState,
enableMFA,
verifyMFA,
disableMFA,
};
};
5.4.5. Security Best Practices
Token Storage Security
// Secure token storage with encryption
class SecureStorage {
private static encrypt(data: string): string {
// Implement encryption logic
return btoa(data); // Simple base64 for demo
}
private static decrypt(encryptedData: string): string {
// Implement decryption logic
return atob(encryptedData); // Simple base64 for demo
}
static setItem(key: string, value: string): void {
const encrypted = this.encrypt(value);
localStorage.setItem(key, encrypted);
}
static getItem(key: string): string | null {
const encrypted = localStorage.getItem(key);
if (!encrypted) return null;
try {
return this.decrypt(encrypted);
} catch {
return null;
}
}
static removeItem(key: string): void {
localStorage.removeItem(key);
}
}
// Use secure storage for tokens
SecureStorage.setItem('accessToken', token);
const token = SecureStorage.getItem('accessToken');
CSRF Protection
const useCSRFProtection = () => {
const [csrfToken, setCsrfToken] = useState<string | null>(null);
useEffect(() => {
const fetchCSRFToken = async () => {
try {
const response = await fetch('/api/csrf-token');
const { token } = await response.json();
setCsrfToken(token);
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
}
};
fetchCSRFToken();
}, []);
const getHeaders = () => ({
'X-CSRF-Token': csrfToken,
});
return { csrfToken, getHeaders };
};
5.4.6. Error Handling and User Experience
Auth Error Boundary
class AuthErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error) {
if (error.message.includes('401') || error.message.includes('403')) {
return { hasError: true };
}
return null;
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
if (error.message.includes('401')) {
// Redirect to login
window.location.href = '/login';
}
}
render() {
if (this.state.hasError) {
return <Navigate to="/login" replace />;
}
return this.props.children;
}
}
This comprehensive authentication system provides secure, user-friendly authentication with proper token management, role-based access control, and advanced security features.
Design a robust data fetching layer for a React application. How would you handle caching, error states, and optimistic updates?
Answer:
A robust data fetching layer is crucial for modern React applications. Here's a comprehensive approach that handles caching, error states, optimistic updates, and more:
5.5.1. Core Data Fetching Architecture
Custom Hook for Data Fetching
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
interface FetchOptions {
enabled?: boolean;
retry?: number;
retryDelay?: number;
staleTime?: number;
cacheTime?: number;
onSuccess?: (data: any) => void;
onError?: (error: Error) => void;
}
const useFetch = <T>(
url: string,
options: FetchOptions = {}
): FetchState<T> => {
const {
enabled = true,
retry = 3,
retryDelay = 1000,
staleTime = 5 * 60 * 1000, // 5 minutes
cacheTime = 10 * 60 * 1000, // 10 minutes
onSuccess,
onError,
} = options;
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: false,
error: null,
refetch: () => Promise.resolve(),
});
const cache = useRef<Map<string, { data: T; timestamp: number }>>(new Map());
const fetchData = useCallback(async (retryCount = 0): Promise<void> => {
if (!enabled) return;
// Check cache first
const cached = cache.current.get(url);
if (cached && Date.now() - cached.timestamp < staleTime) {
setState(prev => ({ ...prev, data: cached.data, loading: false }));
onSuccess?.(cached.data);
return;
}
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Cache the data
cache.current.set(url, { data, timestamp: Date.now() });
setState(prev => ({ ...prev, data, loading: false }));
onSuccess?.(data);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
if (retryCount < retry) {
// Retry with exponential backoff
setTimeout(() => {
fetchData(retryCount + 1);
}, retryDelay * Math.pow(2, retryCount));
} else {
setState(prev => ({ ...prev, error: err, loading: false }));
onError?.(err);
}
}
}, [url, enabled, retry, retryDelay, staleTime, onSuccess, onError]);
const refetch = useCallback(() => {
cache.current.delete(url);
return fetchData();
}, [fetchData, url]);
useEffect(() => {
fetchData();
}, [fetchData]);
// Cleanup old cache entries
useEffect(() => {
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, value] of cache.current.entries()) {
if (now - value.timestamp > cacheTime) {
cache.current.delete(key);
}
}
}, 60000); // Cleanup every minute
return () => clearInterval(cleanup);
}, [cacheTime]);
return { ...state, refetch };
};
5.5.2. Advanced Caching with React Query
React Query Setup
import { QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 1,
},
},
});
// Custom hooks for different data types
export const useUsers = () => {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
select: (data) => data.users,
});
};
export const useUser = (id: string) => {
return useQuery({
queryKey: ['users', id],
queryFn: () => fetch(`/api/users/${id}`).then(res => res.json()),
enabled: !!id,
});
};
export const useCreateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData: CreateUserData) =>
fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
}).then(res => res.json()),
onSuccess: (newUser) => {
// Invalidate and refetch users list
queryClient.invalidateQueries({ queryKey: ['users'] });
// Optimistically update the cache
queryClient.setQueryData(['users', newUser.id], newUser);
},
onError: (error) => {
console.error('Failed to create user:', error);
},
});
};
5.5.3. Optimistic Updates
Optimistic Update Implementation
interface OptimisticUpdate<T> {
queryKey: string[];
updateFn: (oldData: T) => T;
rollbackFn?: (oldData: T) => T;
}
const useOptimisticMutation = <T, TVariables>(
mutationFn: (variables: TVariables) => Promise<T>,
optimisticUpdate: OptimisticUpdate<T>
) => {
const queryClient = useQueryClient();
const [isOptimistic, setIsOptimistic] = useState(false);
return useMutation({
mutationFn,
onMutate: async (variables) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: optimisticUpdate.queryKey });
// Snapshot previous value
const previousData = queryClient.getQueryData<T>(optimisticUpdate.queryKey);
// Optimistically update
if (previousData) {
queryClient.setQueryData(
optimisticUpdate.queryKey,
optimisticUpdate.updateFn(previousData)
);
setIsOptimistic(true);
}
return { previousData };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousData && optimisticUpdate.rollbackFn) {
queryClient.setQueryData(
optimisticUpdate.queryKey,
optimisticUpdate.rollbackFn(context.previousData)
);
}
setIsOptimistic(false);
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey: optimisticUpdate.queryKey });
setIsOptimistic(false);
},
});
};
// Usage example
const useToggleLike = () => {
return useOptimisticMutation(
(postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }).then(res => res.json()),
{
queryKey: ['posts'],
updateFn: (oldData) => ({
...oldData,
posts: oldData.posts.map(post =>
post.id === postId
? { ...post, liked: !post.liked, likes: post.liked ? post.likes - 1 : post.likes + 1 }
: post
),
}),
rollbackFn: (oldData) => ({
...oldData,
posts: oldData.posts.map(post =>
post.id === postId
? { ...post, liked: !post.liked, likes: post.liked ? post.likes + 1 : post.likes - 1 }
: post
),
}),
}
);
};
5.5.4. Error Handling and Retry Logic
Advanced Error Handling
interface ApiError extends Error {
status?: number;
code?: string;
details?: any;
}
class ApiErrorHandler {
static async handleResponse(response: Response): Promise<any> {
if (!response.ok) {
const error: ApiError = new Error(`HTTP ${response.status}: ${response.statusText}`);
error.status = response.status;
try {
const errorData = await response.json();
error.details = errorData;
error.message = errorData.message || error.message;
} catch {
// If response is not JSON, use status text
}
throw error;
}
return response.json();
}
static isRetryableError(error: ApiError): boolean {
if (!error.status) return true; // Network errors are retryable
// Retry on server errors and rate limiting
return error.status >= 500 || error.status === 429;
}
static getRetryDelay(attempt: number, baseDelay = 1000): number {
// Exponential backoff with jitter
const delay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 0.1 * delay;
return Math.min(delay + jitter, 30000); // Max 30 seconds
}
}
const useApiCall = <T>(
url: string,
options: RequestInit = {},
retryOptions = { maxRetries: 3, baseDelay: 1000 }
) => {
const [state, setState] = useState<{
data: T | null;
loading: boolean;
error: ApiError | null;
}>({
data: null,
loading: false,
error: null,
});
const execute = useCallback(async (retryCount = 0): Promise<T> => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const response = await fetch(url, options);
const data = await ApiErrorHandler.handleResponse(response);
setState({ data, loading: false, error: null });
return data;
} catch (error) {
const apiError = error as ApiError;
if (
retryCount < retryOptions.maxRetries &&
ApiErrorHandler.isRetryableError(apiError)
) {
const delay = ApiErrorHandler.getRetryDelay(retryCount, retryOptions.baseDelay);
await new Promise(resolve => setTimeout(resolve, delay));
return execute(retryCount + 1);
}
setState({ data: null, loading: false, error: apiError });
throw apiError;
}
}, [url, options, retryOptions]);
return { ...state, execute };
};
5.5.5. Real-time Data with WebSockets
WebSocket Integration
interface WebSocketMessage {
type: string;
payload: any;
timestamp: number;
}
const useWebSocket = (url: string, options: { reconnect?: boolean; reconnectInterval?: number } = {}) => {
const { reconnect = true, reconnectInterval = 3000 } = options;
const [socket, setSocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const [error, setError] = useState<Event | null>(null);
const connect = useCallback(() => {
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
setError(null);
};
ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
setLastMessage(message);
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
}
};
ws.onclose = () => {
setIsConnected(false);
if (reconnect) {
setTimeout(connect, reconnectInterval);
}
};
ws.onerror = (event) => {
setError(event);
};
setSocket(ws);
}, [url, reconnect, reconnectInterval]);
const sendMessage = useCallback((message: any) => {
if (socket && isConnected) {
socket.send(JSON.stringify(message));
}
}, [socket, isConnected]);
const disconnect = useCallback(() => {
if (socket) {
socket.close();
setSocket(null);
}
}, [socket]);
useEffect(() => {
connect();
return disconnect;
}, [connect, disconnect]);
return {
isConnected,
lastMessage,
error,
sendMessage,
disconnect,
reconnect: connect,
};
};
// Usage with React Query for real-time updates
const useRealtimePosts = () => {
const queryClient = useQueryClient();
const { lastMessage } = useWebSocket('ws://localhost:8080/posts');
useEffect(() => {
if (lastMessage) {
switch (lastMessage.type) {
case 'POST_CREATED':
queryClient.invalidateQueries({ queryKey: ['posts'] });
break;
case 'POST_UPDATED':
queryClient.setQueryData(['posts', lastMessage.payload.id], lastMessage.payload);
break;
case 'POST_DELETED':
queryClient.setQueryData(['posts'], (old: any) =>
old?.filter((post: any) => post.id !== lastMessage.payload.id)
);
break;
}
}
}, [lastMessage, queryClient]);
return useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
});
};
5.5.6. Offline Support and Background Sync
Service Worker Integration
// service-worker.js
const CACHE_NAME = 'api-cache-v1';
const OFFLINE_QUEUE = 'offline-queue';
// Cache API responses
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((response) => {
if (response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
}).catch(() => {
// Return cached version or queue for later
return caches.match(event.request);
});
})
);
}
});
// Background sync for offline actions
self.addEventListener('sync', (event) => {
if (event.tag === 'background-sync') {
event.waitUntil(processOfflineQueue());
}
});
const processOfflineQueue = async () => {
const queue = await getOfflineQueue();
for (const request of queue) {
try {
await fetch(request.url, request.options);
await removeFromOfflineQueue(request.id);
} catch (error) {
console.error('Failed to sync request:', error);
}
}
};
Offline-Aware Data Fetching
const useOfflineAwareFetch = <T>(url: string, options: RequestInit = {}) => {
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [offlineQueue, setOfflineQueue] = useState<any[]>([]);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const executeRequest = useCallback(async (): Promise<T> => {
if (!isOnline && options.method !== 'GET') {
// Queue non-GET requests when offline
const queuedRequest = {
id: Date.now().toString(),
url,
options,
timestamp: Date.now(),
};
setOfflineQueue(prev => [...prev, queuedRequest]);
throw new Error('Request queued for offline sync');
}
const response = await fetch(url, options);
return ApiErrorHandler.handleResponse(response);
}, [url, options, isOnline]);
return { executeRequest, isOnline, offlineQueue };
};
5.5.7. Performance Optimization
Request Deduplication
class RequestDeduplicator {
private static pendingRequests = new Map<string, Promise<any>>();
static async deduplicate<T>(key: string, requestFn: () => Promise<T>): Promise<T> {
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key)!;
}
const promise = requestFn().finally(() => {
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, promise);
return promise;
}
}
const useDeduplicatedFetch = <T>(url: string) => {
return useQuery({
queryKey: [url],
queryFn: () => RequestDeduplicator.deduplicate(url, () =>
fetch(url).then(res => res.json())
),
});
};
This comprehensive data fetching layer provides robust caching, error handling, optimistic updates, real-time capabilities, offline support, and performance optimizations for modern React applications.