frontend / react / mvc_pattern.md

MVC Pattern in React

3 interview angles 12 min read source

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:

// 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.