Web Development

Clean Architecture in React Applications

February 28, 2025
6 min read

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:

  1. Local State: Component-specific data
  2. Context: Shared data within a feature
  3. URL State: Filter, pagination, etc.
  4. Server State: React Query or SWR
  5. 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

  1. Consistency > Perfection: Pick patterns and stick to them
  2. Document Decisions: Future developers (including you) will thank you
  3. Refactor Continuously: Don't wait for "the big refactor"
  4. Invest in DevEx: Good tooling pays dividends
  5. 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.

Tags:
React
Architecture
Best Practices
Code Quality
Frontend

Want to read more articles?