Learn the essential patterns and practices for building maintainable, scalable React applications that can grow with your business needs.
As React applications grow in complexity, maintaining code quality and performance becomes increasingly challenging. In this comprehensive guide, we'll explore proven strategies and patterns that help you build React applications that scale gracefully.
Separating logic from presentation is crucial for maintainable code:
// Presentational Component
interface UserCardProps {
user: User;
onEdit: (id: string) => void;
isLoading: boolean;
}
const UserCard: React.FC<UserCardProps> = ({ user, onEdit, isLoading }) => (
<div className="user-card">
<h3>{user.name}</h3>
<p>{user.email}</p>
<button
onClick={() => onEdit(user.id)}
disabled={isLoading}
>
Edit User
</button>
</div>
);
// Container Component
const UserCardContainer: React.FC<{ userId: string }> = ({ userId }) => {
const { user, isLoading, updateUser } = useUser(userId);
const handleEdit = useCallback((id: string) => {
updateUser(id, { /* updated data */ });
}, [updateUser]);
return (
<UserCard
user={user}
onEdit={handleEdit}
isLoading={isLoading}
/>
);
};
For complex UI components with multiple interconnected parts:
interface TabsContextValue {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
const Tabs = ({ children, defaultTab }: TabsProps) => {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
};
const TabList = ({ children }: { children: ReactNode }) => (
<div className="tab-list">{children}</div>
);
const Tab = ({ value, children }: TabProps) => {
const context = useContext(TabsContext);
const isActive = context?.activeTab === value;
return (
<button
className={`tab ${isActive ? 'active' : ''}`}
onClick={() => context?.setActiveTab(value)}
>
{children}
</button>
);
};
// Usage
<Tabs defaultTab="overview">
<TabList>
<Tab value="overview">Overview</Tab>
<Tab value="analytics">Analytics</Tab>
</TabList>
<TabPanels>
<TabPanel value="overview">Overview content</TabPanel>
<TabPanel value="analytics">Analytics content</TabPanel>
</TabPanels>
</Tabs>
Start with local state and lift up only when necessary:
// Good: Local state for component-specific data
const SearchInput = () => {
const [query, setQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSearch = async () => {
setIsLoading(true);
// Perform search
setIsLoading(false);
};
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={handleSearch} disabled={isLoading}>
Search
</button>
</div>
);
};
Use Context for data that multiple components need:
interface AppStateContextValue {
user: User | null;
theme: 'light' | 'dark';
updateTheme: (theme: 'light' | 'dark') => void;
}
const AppStateContext = createContext<AppStateContextValue | null>(null);
export const AppStateProvider = ({ children }: { children: ReactNode }) => {
const [user, setUser] = useState<User | null>(null);
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const updateTheme = useCallback((newTheme: 'light' | 'dark') => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
}, []);
return (
<AppStateContext.Provider value={{ user, theme, updateTheme }}>
{children}
</AppStateContext.Provider>
);
};
Use React.memo and useMemo strategically:
// Memoize expensive calculations
const ExpensiveComponent = ({ data, filters }) => {
const processedData = useMemo(() => {
return data.filter(item =>
filters.every(filter => filter(item))
).sort((a, b) => a.priority - b.priority);
}, [data, filters]);
return <DataVisualization data={processedData} />;
};
// Memoize components that receive stable props
const MemoizedUserCard = React.memo(UserCard, (prevProps, nextProps) => {
return prevProps.user.id === nextProps.user.id &&
prevProps.user.updatedAt === nextProps.user.updatedAt;
});
Implement strategic code splitting:
// Route-based code splitting
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));
const App = () => (
<Router>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</Router>
);
// Component-based code splitting
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const AnalyticsPage = () => {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>Analytics</h1>
<button onClick={() => setShowChart(true)}>
Load Chart
</button>
{showChart && (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
};
interface UseApiOptions<T> {
initialData?: T;
refetchOnMount?: boolean;
}
function useApi<T>(
url: string,
options: UseApiOptions<T> = {}
) {
const [data, setData] = useState<T | null>(options.initialData || null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
if (options.refetchOnMount !== false) {
fetchData();
}
}, [fetchData, options.refetchOnMount]);
return { data, loading, error, refetch: fetchData };
}
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error('Error reading from localStorage:', error);
return initialValue;
}
});
const setValue = useCallback((value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Error writing to localStorage:', error);
}
}, [key, storedValue]);
return [storedValue, setValue] as const;
}
import { render, screen, fireEvent } from '@testing-library/react';
import { SearchInput } from './SearchInput';
describe('SearchInput', () => {
it('calls onSearch when button is clicked', () => {
const mockOnSearch = jest.fn();
render(<SearchInput onSearch={mockOnSearch} />);
const input = screen.getByRole('textbox');
const button = screen.getByRole('button', { name: /search/i });
fireEvent.change(input, { target: { value: 'test query' } });
fireEvent.click(button);
expect(mockOnSearch).toHaveBeenCalledWith('test query');
});
});
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';
describe('useLocalStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns initial value when localStorage is empty', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
expect(result.current[0]).toBe('initial');
});
it('updates localStorage when value changes', () => {
const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
act(() => {
result.current[1]('updated');
});
expect(localStorage.getItem('test-key')).toBe('"updated"');
});
});
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<
{ children: ReactNode; fallback?: ComponentType<{ error: Error }> },
ErrorBoundaryState
> {
constructor(props: any) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
// Log to error reporting service
}
render() {
if (this.state.hasError) {
const FallbackComponent = this.props.fallback || DefaultErrorFallback;
return <FallbackComponent error={this.state.error!} />;
}
return this.props.children;
}
}
// Usage
<ErrorBoundary fallback={CustomErrorFallback}>
<App />
</ErrorBoundary>
Building scalable React applications requires thoughtful architecture, performance optimization, and robust testing strategies. By implementing these patterns and practices, you'll create applications that not only perform well today but can adapt and grow with your evolving requirements.
Key takeaways: