Frontend / React / mvc_pattern.md

MVC Pattern in React

Updated 3 interview angles 12 min read source
On this page9
  1. Table of Contents
  2. What is MVC Pattern?
  3. MVC Implementation in React
  4. Common MVC Patterns
  5. Advanced MVC Patterns
  6. Best Practices
  7. Common Interview Questions
  8. Summary
  9. Interview angle

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

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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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:

javascript
// 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 3

  • “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.