Clean Architecture in React Applications
After maintaining React applications with hundreds of components and millions of users, I've learned that good architecture isn't optional—it's essential for long-term success.
The Problem with "Just Start Coding"
Most React projects start with good intentions but quickly devolve into spaghetti code. Components grow to hundreds of lines, business logic is scattered everywhere, and making changes becomes a nightmare.
Core Principles
1. Separation of Concerns
Your React components should primarily handle presentation. Business logic, data fetching, and state management belong elsewhere.
Bad:
function UserProfile() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => {
// Complex transformation logic
const transformed = {
...data,
fullName: data.firstName + ' ' + data.lastName,
// More logic...
};
setUser(transformed);
});
}, []);
// Rendering logic mixed with business logic
}
Good:
function UserProfile() {
const { user, loading } = useUser();
if (loading) return <LoadingSpinner />;
return <UserCard user={user} />;
}
2. Folder Structure That Scales
Here's the structure I use for large applications:
src/
├── features/ # Feature-based organization
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── types/
│ └── dashboard/
├── shared/ # Shared across features
│ ├── components/
│ ├── hooks/
│ ├── utils/
│ └── types/
├── core/ # Core business logic
│ ├── api/
│ ├── store/
│ └── config/
└── app/ # App-level concerns
├── routes/
└── layout/
3. Custom Hooks for Logic
Extract logic into custom hooks:
function useUserProfile(userId: string) {
const { data, error, isLoading } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const fullName = useMemo(
() => data ? \`\${data.firstName} \${data.lastName}\` : '',
[data]
);
return { user: data, fullName, error, isLoading };
}
State Management Strategy
Don't reach for Redux immediately. Here's my hierarchy:
- Local State: Component-specific data
- Context: Shared data within a feature
- URL State: Filter, pagination, etc.
- Server State: React Query or SWR
- Global State: Only when truly global (Redux, Zustand)
Component Patterns
Container/Presenter Pattern
// Container - handles logic
function UserProfileContainer({ userId }: Props) {
const { user, isLoading, error } = useUserProfile(userId);
const { updateUser } = useUserMutations();
return (
<UserProfileView
user={user}
isLoading={isLoading}
error={error}
onUpdate={updateUser}
/>
);
}
// Presenter - pure presentation
function UserProfileView({ user, isLoading, error, onUpdate }: Props) {
if (isLoading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<div>
<UserAvatar src={user.avatar} />
<UserInfo user={user} onUpdate={onUpdate} />
</div>
);
}
Testing Strategy
Clean architecture makes testing easier:
describe('useUserProfile', () => {
it('transforms user data correctly', () => {
const { result } = renderHook(() => useUserProfile('123'));
expect(result.current.fullName).toBe('John Doe');
});
});
Type Safety
TypeScript isn't optional for large applications:
// Define domain types
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
}
// Use discriminated unions for states
type UserState =
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: User };
Performance Optimization
1. Code Splitting
const Dashboard = lazy(() => import('./features/dashboard'));
const Settings = lazy(() => import('./features/settings'));
2. Memo Strategically
Don't memo everything. Profile first, then optimize:
const ExpensiveComponent = memo(function ExpensiveComponent({ data }) {
// Heavy computation
return <div>{/* ... */}</div>;
});
3. Virtualize Long Lists
Use react-window or react-virtual for long lists.
Error Boundaries
Always wrap features in error boundaries:
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Routes />
</ErrorBoundary>
);
}
Lessons from Production
- Consistency > Perfection: Pick patterns and stick to them
- Document Decisions: Future developers (including you) will thank you
- Refactor Continuously: Don't wait for "the big refactor"
- Invest in DevEx: Good tooling pays dividends
- Monitor Performance: Use React DevTools Profiler
Common Pitfalls
- Premature Optimization: Build first, optimize later
- Over-abstraction: Don't create abstractions until you need them
- Inconsistent Patterns: Choose conventions and enforce them
- Neglecting Tests: Tests enable confident refactoring
- Ignoring Bundle Size: Monitor and optimize regularly
Conclusion
Clean architecture in React isn't about following rules dogmatically—it's about making your codebase maintainable and your team productive.
Start simple, add complexity only when needed, and always optimize for readability. Your future self will thank you.
Want to read more articles?