
Building Scalable React Applications
As React applications grow in complexity, having a solid architecture becomes crucial for maintainability and team productivity. Here are the key patterns and strategies I’ve found effective for building scalable React applications.
1. Feature-Based Folder Structure
Organize your code by features rather than file types:
src/
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── types/
│ ├── dashboard/
│ └── profile/
├── shared/
│ ├── components/
│ ├── hooks/
│ ├── utils/
│ └── types/
└── app/
├── store/
└── providers/
2. Custom Hooks for Logic Separation
Extract complex logic into custom hooks:
// hooks/useAuth.ts
export function useAuth() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
setUser(user);
setLoading(false);
});
return unsubscribe;
}, []);
const login = useCallback(async (email: string, password: string) => {
try {
await auth.signInWithEmailAndPassword(email, password);
} catch (error) {
throw new Error('Login failed');
}
}, []);
return { user, loading, login };
}
3. Context for Global State
Use React Context for global state that doesn’t need complex state management:
interface AppContextType {
theme: 'light' | 'dark';
toggleTheme: () => void;
user: User | null;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
export function AppProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const { user } = useAuth();
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
}, []);
return (
<AppContext.Provider value={{ theme, toggleTheme, user }}>
{children}
</AppContext.Provider>
);
}
export function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within AppProvider');
}
return context;
}
4. Error Boundaries
Implement error boundaries to gracefully handle errors:
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<
PropsWithChildren<{}>,
ErrorBoundaryState
> {
constructor(props: PropsWithChildren<{}>) {
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);
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong</h2>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
5. Performance Optimization
Use React’s built-in optimization tools:
// Memoize expensive calculations
const expensiveValue = useMemo(() => {
return performExpensiveCalculation(data);
}, [data]);
// Memoize components
const MemoizedComponent = memo(({ data }: { data: Data[] }) => {
return (
<div>
{data.map(item => (
<Item key={item.id} item={item} />
))}
</div>
);
});
// Use callback for stable references
const handleClick = useCallback((id: string) => {
onItemClick(id);
}, [onItemClick]);
Conclusion
Building scalable React applications requires thoughtful architecture decisions from the start. Focus on:
- Clear separation of concerns
- Consistent folder structure
- Proper state management
- Error handling
- Performance optimization
These patterns will help your application grow gracefully and remain maintainable as your team and codebase expand.