You start a React project. Everything goes into one component. Three months later, nobody wants to touch that file — not even you. This article is about how that happens, why MVC was supposed to fix it, why it still breaks, and what actually works in production.
Table of Contents
- Why Do We Even Need Architecture?
- What is MVC?
- MVC in React (With Real Code)
- The Fat MVC Problem
- How Fat MVC Happens (Step by Step)
- MVC vs MVVM vs Flux (Redux)
- Feature-Based Architecture: The Production Solution
- Full Production Example (Todo App)
- Common Mistakes to Avoid
- Which Architecture Should You Choose?
- Key Takeaways
Why Do We Even Need Architecture?
Let's start with a story.
You're building a Todo app. Day 1, you write this:
function App() {
const [todos, setTodos] = useState([]);
useEffect(() => {
fetch('/api/todos').then(r => r.json()).then(setTodos);
}, []);
const deleteTodo = (id) => {
fetch(`/api/todos/${id}`, { method: 'DELETE' });
setTodos(todos.filter(t => t.id !== id));
};
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
{todo.title}
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</div>
))}
</div>
);
}
This works. It's clean. You're happy.
Day 30: You add filtering, sorting, auth, pagination, error handling, loading states, form validation...
That same file is now 400 lines long. A new developer joins the team. They open the file and immediately close it.
This is why architecture matters. Not for day 1. For day 30, day 100, and when you're a team of 10.
What is MVC?
MVC stands for Model + View + Controller.
It's a way to split your code into three clear responsibilities so no single piece does too much.
The Restaurant Analogy 🍽️
Imagine a restaurant:
| MVC Part | Restaurant | Responsibility |
|---|---|---|
| Model | Kitchen | Stores data, handles business logic |
| View | Waiter | Shows things to the user |
| Controller | Manager | Coordinates between kitchen and waiter |
The flow:
Customer (User)
↓
Waiter shows menu (View)
↓
Customer places order (User Action)
↓
Manager takes order (Controller)
↓
Manager tells kitchen what to cook (Model)
↓
Food comes back to customer through waiter (View updates)
Nobody in a restaurant expects the waiter to cook. Nobody expects the chef to serve food. Separation of responsibility.
The Core Idea
User does something
↓
View captures it
↓
Controller decides what to do
↓
Model updates the data
↓
View re-renders with new data
MVC in React (With Real Code)
React is not an MVC framework. It's a view library. But you can structure your code in MVC style.
Here's how each part maps:
| MVC | React Equivalent |
|---|---|
| Model | API calls, State (Redux/Context), backend data |
| View | Components (JSX) |
| Controller | Custom Hooks, Event Handlers |
Example: A Clean Todo App in MVC Style
Model — the data layer
// services/todoService.js
export const fetchTodos = async () => {
const res = await fetch('/api/todos');
return res.json();
};
export const deleteTodoById = async (id) => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
};
This file only knows about data. It has no idea what the UI looks like.
Controller — the logic layer
// hooks/useTodoController.js
import { useState, useEffect } from 'react';
import { fetchTodos, deleteTodoById } from '../services/todoService';
export const useTodoController = () => {
const [todos, setTodos] = useState([]);
useEffect(() => {
fetchTodos().then(setTodos);
}, []);
const deleteTodo = async (id) => {
await deleteTodoById(id);
setTodos(prev => prev.filter(t => t.id !== id));
};
return { todos, deleteTodo };
};
This hook handles logic and coordination. It doesn't care about what the button looks like.
View — the UI layer
// components/TodoList.jsx
const TodoList = ({ todos, onDelete }) => {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.title}
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
);
};
This component only renders. No API calls. No business logic. Just display.
Putting it together
// pages/TodoPage.jsx
import { useTodoController } from '../hooks/useTodoController';
import TodoList from '../components/TodoList';
const TodoPage = () => {
const { todos, deleteTodo } = useTodoController();
return <TodoList todos={todos} onDelete={deleteTodo} />;
};
This is clean MVC. Three files, three responsibilities, nothing overlapping.
The Fat MVC Problem
Here's where it goes wrong.
Imagine your restaurant manager (Controller) starts:
- Taking orders ✅ (his job)
- Cooking food ❌ (not his job)
- Doing billing ❌ (not his job)
- Cleaning tables ❌ (not his job)
- Ordering supplies ❌ (not his job)
He's overloaded. He makes mistakes. He's slow. When he quits, the whole restaurant collapses because only he knew how everything worked.
This is Fat MVC.
What Fat MVC Looks Like in Code
// ❌ Fat Component — everything dumped in one place
const TodoPage = () => {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [filter, setFilter] = useState('all');
const [searchQuery, setSearch] = useState('');
const [user, setUser] = useState(null);
// API call mixed with UI logic
useEffect(() => {
setLoading(true);
fetch('/api/todos')
.then(res => {
if (!res.ok) throw new Error('Failed');
return res.json();
})
.then(data => {
// Business logic inside component
const filtered = data.filter(t => !t.deleted && t.userId === user?.id);
setTodos(filtered);
})
.catch(setError)
.finally(() => setLoading(false));
}, [user]);
// Validation logic mixed in
const deleteTodo = async (id) => {
if (!id || typeof id !== 'number') return;
if (!window.confirm('Are you sure?')) return;
try {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
setTodos(prev => prev.filter(t => t.id !== id));
} catch {
setError('Delete failed');
}
};
// Filtering logic inside component
const filteredTodos = todos.filter(todo => {
if (filter === 'completed') return todo.completed;
if (filter === 'active') return !todo.completed;
return todo.title.includes(searchQuery);
});
// Auth check mixed in
if (!user) return <Login />;
if (loading) return <Spinner />;
if (error) return <ErrorBox message={error} />;
return (
<div>
{/* ... all the UI ... */}
</div>
);
};
This is a real file from a real codebase somewhere. It has:
- State management
- API calls
- Business validation
- Filtering logic
- Error handling
- Auth checks
- UI rendering
All in one component. 😵
Why Fat MVC is Dangerous
1. You can't test it
To test the deleteTodo logic, you have to render the entire component, mock the API, fake the user state, and simulate a button click. That's insane for a simple delete function.
2. You can't reuse it
Need the same delete logic in another component? Copy-paste. Now you have two bugs instead of one.
3. One change breaks everything
You change the API endpoint. Now you have to hunt through 300 lines to find every place that uses it.
4. New developers can't understand it
The fastest way to lose a new teammate is to point at a 500-line component and say "just figure it out."
5. It grows without stopping
Fat components attract more fat. "Oh this file already has everything, let me just add this one thing here..." Three months later it's 800 lines.
How Fat MVC Happens (Step by Step)
Understanding how this happens is just as important as knowing what to do about it.
Stage 1: The Innocent Start (Day 1)
// Simple, clean, totally fine
function TodoApp() {
const [todos, setTodos] = useState([]);
useEffect(() => {
fetch('/api/todos').then(r => r.json()).then(setTodos);
}, []);
return <TodoList todos={todos} />;
}
100 lines. Easy to read. No problem.
Stage 2: Feature Creep (Week 3)
The client wants filters. And search. And pagination.
// Still manageable... but growing
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
// ... 50 more lines of logic
}
200 lines. A bit long, but okay.
Stage 3: The Collapse (Month 2)
Authentication. Error boundaries. User preferences. Notifications. Bulk delete. Analytics tracking.
// 😱 No one will ever refactor this
function TodoApp() {
// 15 useState hooks
// 5 useEffect hooks
// 20 handler functions
// 200 lines of JSX
}
600 lines. Nobody touches it. The Fat MVC problem is complete.
The lesson: Fat MVC doesn't happen in one bad decision. It happens through a hundred small "just this once" decisions.
MVC vs MVVM vs Flux (Redux)
Before we get to the solution, let's understand the three main architecture patterns — because modern React doesn't use pure MVC at all.
The Same Restaurant, Three Management Styles
MVC — The Traditional Manager
Customer → Waiter → Manager → Kitchen → Waiter → Customer
The manager (controller) manually coordinates everything. Explicit but can become overwhelming.
MVVM — The Digital Self-Service Kiosk
Customer ↔ Kiosk Screen ↔ Order System ↔ Kitchen
The screen (View) is directly connected to the order system (ViewModel). When you change your order, the screen updates automatically. No manager needed — it's reactive.
Flux/Redux — The Central Command System
Customer → Places Order Form → Central System → Updates All Screens → Customer sees update
All actions go through ONE central place. Nothing updates directly. Everything is tracked. Predictable but formal.
The Technical Comparison
| Feature | MVC | MVVM | Flux (Redux) |
|---|---|---|---|
| Data Flow | Bidirectional | Two-way binding | One-way only |
| State Location | Scattered | In ViewModel | Centralized store |
| Complexity | Medium | Medium | High |
| Debugging | Tricky | Medium | Easy (time travel!) |
| Boilerplate | Low | Low | High |
| Best For | Small/medium apps | Most React apps | Large, complex apps |
React's Reality
React actually behaves like MVVM by default:
// This IS MVVM — you just don't call it that
function Counter() {
const [count, setCount] = useState(0); // ← ViewModel (state + logic)
return (
// ↓ View (automatically updates when state changes)
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
-
useState= ViewModel - JSX = View
- The two-way binding = React's reactivity
For large apps, teams add Redux (Flux) on top:
Component (View)
↓
dispatch(action) ← User action
↓
Reducer ← Handles the action
↓
Store ← Central state
↓
Component re-renders ← View updates
Feature-Based Architecture: The Production Solution
Pure MVC breaks at scale. MVVM is what React naturally does. Flux/Redux solves global state. But none of these tell you how to organize your folders.
The real answer to Fat MVC in production is: Feature-Based Architecture with proper layering.
The Core Idea
Instead of organizing by type (all components together, all services together):
❌ Type-Based (Breaks at scale)
src/
├── components/ ← ALL components from ALL features
├── services/ ← ALL services from ALL features
├── hooks/ ← ALL hooks from ALL features
You organize by feature (everything for one feature lives together):
✅ Feature-Based (Scales well)
src/
├── features/
│ ├── todos/ ← Everything for the todo feature
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── api/
│ ├── auth/ ← Everything for auth
│ └── dashboard/ ← Everything for dashboard
├── shared/ ← Shared across features
│ ├── components/ ← Buttons, Inputs, Modal...
│ └── utils/
└── pages/ ← Just composition
The Four Layers (Inside Each Feature)
Every feature has four layers. Each layer has ONE job:
┌─────────────────────────────┐
│ Layer 1: API │ ← Only HTTP calls. Nothing else.
├─────────────────────────────┤
│ Layer 2: Service │ ← Business logic + data transformation
├─────────────────────────────┤
│ Layer 3: Hook (Controller) │ ← State management + side effects
├─────────────────────────────┤
│ Layer 4: Component (View) │ ← Only renders JSX
└─────────────────────────────┘
Full Production Example (Todo App)
Let's build the same Todo app — but production-grade this time.
Layer 1: API (Pure Network Calls)
// features/todos/api/todoApi.js
const BASE_URL = '/api/todos';
export const todoApi = {
getAll: () =>
fetch(BASE_URL).then(res => {
if (!res.ok) throw new Error('Failed to fetch todos');
return res.json();
}),
delete: (id) =>
fetch(`${BASE_URL}/${id}`, { method: 'DELETE' }).then(res => {
if (!res.ok) throw new Error('Failed to delete todo');
}),
create: (todo) =>
fetch(BASE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
}).then(res => res.json()),
};
Rules for this layer:
- Only HTTP calls
- No business logic
- No state
- No UI knowledge
Layer 2: Service (Business Logic)
// features/todos/services/todoService.js
import { todoApi } from '../api/todoApi';
export const todoService = {
// Business rule: only return non-deleted, non-archived todos
getActiveTodos: async () => {
const todos = await todoApi.getAll();
return todos.filter(t => !t.deleted && !t.archived);
},
// Business rule: title must be non-empty before deleting
deleteTodo: async (id) => {
if (!id || typeof id !== 'number') {
throw new Error('Invalid todo ID');
}
await todoApi.delete(id);
},
// Business rule: enforce max title length
createTodo: async (title) => {
if (!title.trim()) throw new Error('Title cannot be empty');
if (title.length > 200) throw new Error('Title too long');
return todoApi.create({ title: title.trim() });
},
};
Rules for this layer:
- Business logic and validation only
- Calls the API layer
- No state management
- No React code
Layer 3: Hook (Controller / State)
// features/todos/hooks/useTodos.js
import { useState, useEffect, useCallback } from 'react';
import { todoService } from '../services/todoService';
export const useTodos = () => {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Load todos on mount
useEffect(() => {
todoService.getActiveTodos()
.then(setTodos)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, []);
// Delete handler
const deleteTodo = useCallback(async (id) => {
try {
await todoService.deleteTodo(id);
setTodos(prev => prev.filter(t => t.id !== id));
} catch (err) {
setError(err.message);
}
}, []);
// Create handler
const createTodo = useCallback(async (title) => {
try {
const newTodo = await todoService.createTodo(title);
setTodos(prev => [...prev, newTodo]);
} catch (err) {
setError(err.message);
}
}, []);
return { todos, loading, error, deleteTodo, createTodo };
};
Rules for this layer:
- Manages component state
- Calls the service layer
- Returns clean data and functions to the view
- No JSX
Layer 4: Component (View)
// features/todos/components/TodoList.jsx
const TodoList = ({ todos, onDelete }) => {
return (
<ul className="todo-list">
{todos.map(todo => (
<li key={todo.id} className="todo-item">
<span>{todo.title}</span>
<button
onClick={() => onDelete(todo.id)}
aria-label={`Delete ${todo.title}`}
>
Delete
</button>
</li>
))}
</ul>
);
};
export default TodoList;
// features/todos/components/CreateTodoForm.jsx
import { useState } from 'react';
const CreateTodoForm = ({ onCreate }) => {
const [title, setTitle] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onCreate(title);
setTitle('');
};
return (
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="New todo..."
/>
<button type="submit">Add</button>
</form>
);
};
export default CreateTodoForm;
Rules for this layer:
- Only JSX and styling
- No API calls
- No business logic
- Receives everything through props
The Page (Composition Only)
// pages/TodoPage.jsx
import { useTodos } from '../features/todos/hooks/useTodos';
import TodoList from '../features/todos/components/TodoList';
import CreateTodoForm from '../features/todos/components/CreateTodoForm';
const TodoPage = () => {
const { todos, loading, error, deleteTodo, createTodo } = useTodos();
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h1>My Todos</h1>
<CreateTodoForm onCreate={createTodo} />
<TodoList todos={todos} onDelete={deleteTodo} />
</div>
);
};
export default TodoPage;
This page has zero business logic. It just composes pieces together.
Adding Global State (When You Need It)
For state shared across multiple features (like logged-in user, cart, notifications), use Zustand (modern) or Redux:
// store/todoStore.js (Zustand)
import { create } from 'zustand';
export const useTodoStore = create((set) => ({
todos: [],
setTodos: (todos) => set({ todos }),
removeTodo: (id) => set(state => ({
todos: state.todos.filter(t => t.id !== id)
})),
}));
Now any component anywhere in the app can access or update todos without prop drilling.
The Full Folder Structure
src/
├── features/
│ ├── todos/
│ │ ├── api/
│ │ │ └── todoApi.js ← HTTP only
│ │ ├── services/
│ │ │ └── todoService.js ← Business logic
│ │ ├── hooks/
│ │ │ └── useTodos.js ← State + controller
│ │ └── components/
│ │ ├── TodoList.jsx ← Display list
│ │ └── CreateTodoForm.jsx ← Form
│ │
│ ├── auth/
│ │ ├── api/
│ │ ├── services/
│ │ ├── hooks/
│ │ └── components/
│ │
│ └── dashboard/
│ └── ...
│
├── shared/
│ ├── components/
│ │ ├── Button.jsx
│ │ ├── Input.jsx
│ │ └── Modal.jsx
│ ├── utils/
│ │ └── formatDate.js
│ └── constants/
│ └── config.js
│
├── store/
│ └── todoStore.js ← Global state (Zustand/Redux)
│
└── pages/
└── TodoPage.jsx ← Composition only
Common Mistakes to Avoid
❌ Mistake 1: API calls inside components
// ❌ Don't do this
function TodoList() {
useEffect(() => {
fetch('/api/todos').then(...) // API call in component
}, []);
}
// ✅ Do this — call through hook
function TodoList() {
const { todos } = useTodos(); // clean
}
❌ Mistake 2: Business logic in JSX
// ❌ Don't do this — logic in the view
<button onClick={() => {
if (todo.id && typeof todo.id === 'number') {
fetch(`/api/${todo.id}`, { method: 'DELETE' });
setTodos(todos.filter(t => t.id !== todo.id));
}
}}>
Delete
</button>
// ✅ Do this — clean handler from hook
<button onClick={() => deleteTodo(todo.id)}>
Delete
</button>
❌ Mistake 3: Huge useEffect blocks
// ❌ Don't mix concerns in one effect
useEffect(() => {
fetch('/api/todos').then(setTodos);
fetch('/api/user').then(setUser);
document.title = 'Todos';
analytics.track('page_view');
}, []);
// ✅ Separate effects for separate concerns
useEffect(() => { fetchTodos(); }, []);
useEffect(() => { fetchUser(); }, []);
useEffect(() => { document.title = 'Todos'; }, []);
❌ Mistake 4: Prop drilling through 5 layers
// ❌ Passing props 4 levels deep is painful
<Page user={user}>
<Layout user={user}>
<Sidebar user={user}>
<UserMenu user={user} />
</Sidebar>
</Layout>
</Page>
// ✅ Use Context or Zustand for shared data
const { user } = useAuthStore(); // accessed directly wherever needed
Which Architecture Should You Choose?
Here's a simple decision guide:
Is your app simple? (1-3 pages, 1 developer)
→ Just use components + hooks. No special architecture needed.
Is your app medium size? (5-15 pages, small team)
→ Feature-based structure + MVVM (hooks as ViewModel).
Is your app large? (15+ pages, multiple teams, complex state)
→ Feature-based + Redux/Zustand + strict layering.
Do different clients (web, mobile) share the same backend?
→ Add BFF layer in front of microservices.
The Golden Rule
Start simple. Add structure when the pain is real — not before.
Don't build a 10-layer architecture for a 5-page app. But don't build a 50-page app without layers either. Match the architecture to the actual complexity.
Key Takeaways
MVC separates your app into Model (data), View (UI), and Controller (logic). This is a good idea that prevents everything from mixing together.
React is not MVC. It's closest to MVVM — where hooks act as the ViewModel and JSX is the View.
Fat MVC happens slowly. One small shortcut at a time, until nobody can work with the code anymore.
Fat MVC symptoms: Giant components, business logic in JSX, API calls in the view, copy-pasted code everywhere.
The production solution is Feature-Based Architecture with four clear layers: API → Service → Hook → Component.
Each layer has ONE job. API layer does HTTP. Service does business logic. Hook manages state. Component renders UI.
For global state, use Zustand (simple) or Redux (complex apps). Don't prop drill through more than 2-3 levels.
Don't over-engineer early. Add structure as your app grows, not before.
One Paragraph to Remember
MVC was created to stop everything from mixing together. In React, we naturally end up with MVVM through hooks. But the real danger is Fat MVC — when one file ends up doing everything. The solution isn't a new pattern name. It's discipline: one layer for HTTP, one for business logic, one for state, one for UI. Feature-based folders keep related code together. That's it. That's the whole thing.
Found this useful? Share it with a teammate who still puts fetch calls directly inside components. You might just save their next code review.
Tags: #react #webdev #javascript #architecture #beginners #programming #frontend #systemdesign #cleancode
Top comments (0)