MVC Pattern in React
MVC (Model-View-Controller) pattern in React helps organize code by separating concerns into distinct layers: data management, UI presentation, and user interaction logic.
Table of Contents
- MVC Pattern in React
What is MVC Pattern?
MVC is an architectural pattern that separates an application into three main components: Model (data), View (UI), and Controller (business logic).
Key Concepts:
- Model: Manages data and business logic
- View: Handles UI presentation and user interface
- Controller: Coordinates between Model and View
- Separation of Concerns: Each layer has a specific responsibility
MVC Structure in React:
// Model: Data and business logic
class UserModel {
constructor() {
this.users = [];
}
addUser(user) {
this.users.push(user);
}
getUsers() {
return this.users;
}
}
// View: UI components
function UserListView({ users, onUserClick }) {
return (
<div>
{users.map(user => (
<div key={user.id} onClick={() => onUserClick(user)}>
{user.name}
</div>
))}
</div>
);
}
// Controller: Coordinates Model and View
function UserController() {
const [users, setUsers] = useState([]);
const userModel = new UserModel();
const handleAddUser = (user) => {
userModel.addUser(user);
setUsers(userModel.getUsers());
};
const handleUserClick = (user) => {
console.log('User clicked:', user);
};
return (
<UserListView
users={users}
onUserClick={handleUserClick}
/>
);
}
MVC Implementation in React
1. Model Layer:
// Model: Data management and business logic
class TodoModel {
constructor() {
this.todos = [];
this.listeners = [];
}
// Business logic methods
addTodo(text) {
const todo = {
id: Date.now(),
text,
completed: false,
createdAt: new Date()
};
this.todos.push(todo);
this.notifyListeners();
return todo;
}
toggleTodo(id) {
const todo = this.todos.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
this.notifyListeners();
}
}
deleteTodo(id) {
this.todos = this.todos.filter(t => t.id !== id);
this.notifyListeners();
}
getTodos() {
return [...this.todos];
}
getCompletedTodos() {
return this.todos.filter(t => t.completed);
}
getPendingTodos() {
return this.todos.filter(t => !t.completed);
}
// Observer pattern for updates
subscribe(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notifyListeners() {
this.listeners.forEach(listener => listener(this.getTodos()));
}
}
// Usage
const todoModel = new TodoModel();
2. View Layer:
// View: Pure UI components
function TodoListView({ todos, onToggle, onDelete }) {
return (
<div className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => onToggle(todo.id)}
onDelete={() => onDelete(todo.id)}
/>
))}
</div>
);
}
function TodoItem({ todo, onToggle, onDelete }) {
return (
<div className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={onToggle}
/>
<span>{todo.text}</span>
<button onClick={onDelete}>Delete</button>
</div>
);
}
function TodoForm({ onSubmit }) {
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
onSubmit(text);
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add new todo"
/>
<button type="submit">Add</button>
</form>
);
}
3. Controller Layer:
// Controller: Coordinates Model and View
function TodoController() {
const [todos, setTodos] = useState([]);
const [model] = useState(() => new TodoModel());
useEffect(() => {
// Subscribe to model changes
const unsubscribe = model.subscribe(setTodos);
return unsubscribe;
}, [model]);
const handleAddTodo = (text) => {
model.addTodo(text);
};
const handleToggleTodo = (id) => {
model.toggleTodo(id);
};
const handleDeleteTodo = (id) => {
model.deleteTodo(id);
};
const completedCount = todos.filter(t => t.completed).length;
const pendingCount = todos.length - completedCount;
return (
<div className="todo-app">
<h1>Todo App</h1>
<div className="stats">
<span>Completed: {completedCount}</span>
<span>Pending: {pendingCount}</span>
</div>
<TodoForm onSubmit={handleAddTodo} />
<TodoListView
todos={todos}
onToggle={handleToggleTodo}
onDelete={handleDeleteTodo}
/>
</div>
);
}
Common MVC Patterns
1. User Management MVC:
// Model
class UserModel {
constructor() {
this.users = [];
this.currentUser = null;
this.listeners = [];
}
async fetchUsers() {
try {
const response = await fetch('/api/users');
this.users = await response.json();
this.notifyListeners();
} catch (error) {
console.error('Error fetching users:', error);
}
}
async createUser(userData) {
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData)
});
const newUser = await response.json();
this.users.push(newUser);
this.notifyListeners();
return newUser;
} catch (error) {
console.error('Error creating user:', error);
throw error;
}
}
setCurrentUser(user) {
this.currentUser = user;
this.notifyListeners();
}
subscribe(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notifyListeners() {
this.listeners.forEach(listener => listener({
users: [...this.users],
currentUser: this.currentUser
}));
}
}
// View
function UserListView({ users, onUserSelect, onUserEdit, onUserDelete }) {
return (
<div className="user-list">
{users.map(user => (
<UserCard
key={user.id}
user={user}
onSelect={() => onUserSelect(user)}
onEdit={() => onUserEdit(user)}
onDelete={() => onUserDelete(user.id)}
/>
))}
</div>
);
}
function UserCard({ user, onSelect, onEdit, onDelete }) {
return (
<div className="user-card" onClick={onSelect}>
<h3>{user.name}</h3>
<p>{user.email}</p>
<div className="actions">
<button onClick={(e) => { e.stopPropagation(); onEdit(); }}>
Edit
</button>
<button onClick={(e) => { e.stopPropagation(); onDelete(); }}>
Delete
</button>
</div>
</div>
);
}
// Controller
function UserController() {
const [state, setState] = useState({ users: [], currentUser: null });
const [loading, setLoading] = useState(false);
const [model] = useState(() => new UserModel());
useEffect(() => {
const unsubscribe = model.subscribe(setState);
model.fetchUsers();
return unsubscribe;
}, [model]);
const handleCreateUser = async (userData) => {
setLoading(true);
try {
await model.createUser(userData);
} catch (error) {
console.error('Failed to create user:', error);
} finally {
setLoading(false);
}
};
const handleUserSelect = (user) => {
model.setCurrentUser(user);
};
return (
<div className="user-management">
<h1>User Management</h1>
{loading && <div>Loading...</div>}
<UserListView
users={state.users}
onUserSelect={handleUserSelect}
onUserEdit={(user) => console.log('Edit user:', user)}
onUserDelete={(id) => console.log('Delete user:', id)}
/>
{state.currentUser && (
<div className="selected-user">
<h2>Selected: {state.currentUser.name}</h2>
</div>
)}
</div>
);
}
2. Shopping Cart MVC:
// Model
class CartModel {
constructor() {
this.items = [];
this.listeners = [];
}
addItem(product, quantity = 1) {
const existingItem = this.items.find(item => item.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.push({ ...product, quantity });
}
this.notifyListeners();
}
removeItem(productId) {
this.items = this.items.filter(item => item.id !== productId);
this.notifyListeners();
}
updateQuantity(productId, quantity) {
const item = this.items.find(item => item.id === productId);
if (item) {
item.quantity = quantity;
this.notifyListeners();
}
}
getTotal() {
return this.items.reduce((total, item) => {
return total + (item.price * item.quantity);
}, 0);
}
getItemCount() {
return this.items.reduce((count, item) => count + item.quantity, 0);
}
clear() {
this.items = [];
this.notifyListeners();
}
subscribe(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notifyListeners() {
this.listeners.forEach(listener => listener({
items: [...this.items],
total: this.getTotal(),
itemCount: this.getItemCount()
}));
}
}
// View
function CartView({ items, total, onUpdateQuantity, onRemoveItem, onClear }) {
return (
<div className="cart">
<h2>Shopping Cart</h2>
{items.length === 0 ? (
<p>Your cart is empty</p>
) : (
<>
{items.map(item => (
<CartItem
key={item.id}
item={item}
onUpdateQuantity={onUpdateQuantity}
onRemove={onRemoveItem}
/>
))}
<div className="cart-total">
<h3>Total: ${total.toFixed(2)}</h3>
<button onClick={onClear}>Clear Cart</button>
</div>
</>
)}
</div>
);
}
function CartItem({ item, onUpdateQuantity, onRemove }) {
return (
<div className="cart-item">
<img src={item.image} alt={item.name} />
<div className="item-details">
<h4>{item.name}</h4>
<p>${item.price}</p>
<input
type="number"
min="1"
value={item.quantity}
onChange={(e) => onUpdateQuantity(item.id, parseInt(e.target.value))}
/>
<button onClick={() => onRemove(item.id)}>Remove</button>
</div>
</div>
);
}
// Controller
function CartController() {
const [cartState, setCartState] = useState({ items: [], total: 0, itemCount: 0 });
const [model] = useState(() => new CartModel());
useEffect(() => {
const unsubscribe = model.subscribe(setCartState);
return unsubscribe;
}, [model]);
const handleAddToCart = (product) => {
model.addItem(product);
};
const handleUpdateQuantity = (productId, quantity) => {
model.updateQuantity(productId, quantity);
};
const handleRemoveItem = (productId) => {
model.removeItem(productId);
};
const handleClearCart = () => {
model.clear();
};
return (
<div className="shopping-app">
<CartView
items={cartState.items}
total={cartState.total}
onUpdateQuantity={handleUpdateQuantity}
onRemoveItem={handleRemoveItem}
onClear={handleClearCart}
/>
</div>
);
}
Advanced MVC Patterns
1. MVC with Custom Hooks:
// Custom hook for Model
function useModel(ModelClass) {
const [model] = useState(() => new ModelClass());
const [state, setState] = useState(model.getInitialState());
useEffect(() => {
const unsubscribe = model.subscribe(setState);
return unsubscribe;
}, [model]);
return [state, model];
}
// Custom hook for Controller
function useController(model, actions) {
const controllerActions = useMemo(() => {
return Object.keys(actions).reduce((acc, key) => {
acc[key] = (...args) => actions[key](model, ...args);
return acc;
}, {});
}, [model, actions]);
return controllerActions;
}
// Usage
function TodoApp() {
const [state, model] = useModel(TodoModel);
const actions = {
addTodo: (model, text) => model.addTodo(text),
toggleTodo: (model, id) => model.toggleTodo(id),
deleteTodo: (model, id) => model.deleteTodo(id)
};
const controller = useController(model, actions);
return (
<TodoView
todos={state.todos}
onAddTodo={controller.addTodo}
onToggleTodo={controller.toggleTodo}
onDeleteTodo={controller.deleteTodo}
/>
);
}
2. MVC with Context:
// Model Context
const ModelContext = React.createContext();
function ModelProvider({ model, children }) {
const [state, setState] = useState(model.getInitialState());
useEffect(() => {
const unsubscribe = model.subscribe(setState);
return unsubscribe;
}, [model]);
return (
<ModelContext.Provider value={{ state, model }}>
{children}
</ModelContext.Provider>
);
}
// Controller Context
const ControllerContext = React.createContext();
function ControllerProvider({ children }) {
const { model } = useContext(ModelContext);
const controller = useMemo(() => ({
addTodo: (text) => model.addTodo(text),
toggleTodo: (id) => model.toggleTodo(id),
deleteTodo: (id) => model.deleteTodo(id)
}), [model]);
return (
<ControllerContext.Provider value={controller}>
{children}
</ControllerContext.Provider>
);
}
// View with Context
function TodoView() {
const { state } = useContext(ModelContext);
const controller = useContext(ControllerContext);
return (
<div>
<TodoForm onSubmit={controller.addTodo} />
<TodoList
todos={state.todos}
onToggle={controller.toggleTodo}
onDelete={controller.deleteTodo}
/>
</div>
);
}
// App with Context
function App() {
const model = new TodoModel();
return (
<ModelProvider model={model}>
<ControllerProvider>
<TodoView />
</ControllerProvider>
</ModelProvider>
);
}
3. MVC with Redux-like Pattern:
// Action types
const ACTIONS = {
ADD_TODO: 'ADD_TODO',
TOGGLE_TODO: 'TOGGLE_TODO',
DELETE_TODO: 'DELETE_TODO'
};
// Action creators
const actionCreators = {
addTodo: (text) => ({ type: ACTIONS.ADD_TODO, payload: text }),
toggleTodo: (id) => ({ type: ACTIONS.TOGGLE_TODO, payload: id }),
deleteTodo: (id) => ({ type: ACTIONS.DELETE_TODO, payload: id })
};
// Model with reducer
class ReduxLikeModel {
constructor() {
this.state = { todos: [] };
this.listeners = [];
}
reducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_TODO:
return {
...state,
todos: [...state.todos, {
id: Date.now(),
text: action.payload,
completed: false
}]
};
case ACTIONS.TOGGLE_TODO:
return {
...state,
todos: state.todos.map(todo =>
todo.id === action.payload
? { ...todo, completed: !todo.completed }
: todo
)
};
case ACTIONS.DELETE_TODO:
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.payload)
};
default:
return state;
}
}
dispatch(action) {
this.state = this.reducer(this.state, action);
this.notifyListeners();
}
subscribe(listener) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
notifyListeners() {
this.listeners.forEach(listener => listener(this.state));
}
}
// Controller with dispatch
function ReduxLikeController() {
const [state, setState] = useState({ todos: [] });
const [model] = useState(() => new ReduxLikeModel());
useEffect(() => {
const unsubscribe = model.subscribe(setState);
return unsubscribe;
}, [model]);
const dispatch = useCallback((action) => {
model.dispatch(action);
}, [model]);
return (
<TodoView
todos={state.todos}
onAddTodo={(text) => dispatch(actionCreators.addTodo(text))}
onToggleTodo={(id) => dispatch(actionCreators.toggleTodo(id))}
onDeleteTodo={(id) => dispatch(actionCreators.deleteTodo(id))}
/>
);
}
Best Practices
1. Keep Models Pure:
// Good: Pure model with business logic only
class UserModel {
constructor() {
this.users = [];
}
addUser(user) {
// Business logic only
if (!user.name || !user.email) {
throw new Error('User must have name and email');
}
const newUser = { ...user, id: Date.now() };
this.users.push(newUser);
this.notifyListeners();
return newUser;
}
}
// Bad: Model with UI concerns
class BadUserModel {
constructor() {
this.users = [];
}
addUser(user) {
// Don't mix UI logic in model
if (!user.name) {
alert('Name is required'); // UI concern
}
this.users.push(user);
}
}
2. Keep Views Pure:
// Good: Pure view component
function UserList({ users, onUserClick, onUserDelete }) {
return (
<div>
{users.map(user => (
<div key={user.id}>
<span onClick={() => onUserClick(user)}>{user.name}</span>
<button onClick={() => onUserDelete(user.id)}>Delete</button>
</div>
))}
</div>
);
}
// Bad: View with business logic
function BadUserList({ users }) {
const handleDelete = (userId) => {
// Business logic in view
fetch(`/api/users/${userId}`, { method: 'DELETE' });
};
return (
<div>
{users.map(user => (
<div key={user.id}>
<span>{user.name}</span>
<button onClick={() => handleDelete(user.id)}>Delete</button>
</div>
))}
</div>
);
}
3. Use Controllers for Coordination:
// Good: Controller coordinates model and view
function UserController() {
const [users, setUsers] = useState([]);
const [model] = useState(() => new UserModel());
useEffect(() => {
const unsubscribe = model.subscribe(setUsers);
return unsubscribe;
}, [model]);
const handleAddUser = (userData) => {
try {
model.addUser(userData);
} catch (error) {
console.error('Failed to add user:', error);
}
};
const handleDeleteUser = (userId) => {
model.deleteUser(userId);
};
return (
<UserView
users={users}
onAddUser={handleAddUser}
onDeleteUser={handleDeleteUser}
/>
);
}
4. Separate Data and UI State:
// Good: Separate data and UI state
function TodoController() {
const [todos, setTodos] = useState([]); // Data state
const [loading, setLoading] = useState(false); // UI state
const [error, setError] = useState(null); // UI state
const handleAddTodo = async (text) => {
setLoading(true);
setError(null);
try {
await model.addTodo(text);
} catch (error) {
setError(error.message);
} finally {
setLoading(false);
}
};
return (
<TodoView
todos={todos}
loading={loading}
error={error}
onAddTodo={handleAddTodo}
/>
);
}
Common Interview Questions
Q: What is the MVC pattern in React?
- An architectural pattern that separates an application into Model (data), View (UI), and Controller (coordination) layers.
Q: How do you implement MVC in React?
- Models handle data and business logic, Views are pure UI components, and Controllers coordinate between them using state and props.
Q: What are the benefits of using MVC in React?
- Better code organization, separation of concerns, easier testing, and maintainable codebase.
Q: How does MVC differ from traditional React patterns?
- MVC provides more structured separation of concerns compared to mixing logic in components.
Q: What is the role of the Model in React MVC?
- Manages data, business logic, and provides methods for data manipulation.
Q: What is the role of the View in React MVC?
- Pure UI components that receive props and render based on data.
Q: What is the role of the Controller in React MVC?
- Coordinates between Model and View, handles user interactions, and manages state.
Q: How do you handle state management in MVC?
- Models manage data state, Controllers manage UI state, and Views receive state as props.
Q: Can you use hooks with MVC pattern?
- Yes, hooks can be used in Controllers for state management and side effects.
Q: What are the alternatives to MVC in React?
- Flux, Redux, Context API, and other state management patterns.
Summary
- MVC pattern provides structured separation of concerns in React applications
- Models handle data and business logic
- Views are pure UI components
- Controllers coordinate between Model and View
- Benefits include better organization, testing, and maintainability
- Implementation can use custom hooks, context, or Redux-like patterns
- Best practices include keeping layers pure and separating data from UI state
- Understanding MVC is crucial for building scalable React applications
Interview angle
- “Is React MVC?” - no. React is the view layer; it has no controller and no prescribed model. Flux, and Redux after it, added unidirectional data flow specifically as an alternative to MVC’s bidirectional bindings.
- “Why did unidirectional flow win in this space?” - state changes have one path (action to store to view), so a wrong value on screen is traceable backwards. Two-way binding makes “who changed this” genuinely hard in a large tree.
- “How do you layer a React app today?” - separate server state (a query library that owns caching and revalidation) from client state (component state, or a small store), and keep components as presentation. Putting server data in a global client store is the mistake this split exists to prevent.