MVC Pattern in Modern Frontend Applications
Apply the Model-View-Controller pattern to React and Vue applications to separate data, UI, and interaction logic for maintainable component architecture
Model-View-Controller separates an application into three components: Model (data and rules), View (presentation), and Controller (input handling and coordination). While frameworks like React and Vue blur these boundaries, applying MVC discipline prevents components from becoming unmaintainable mashups of state, UI, and side effects.
When to Use This
- Components grow beyond 200 lines because they mix data fetching, transformation, and rendering. See Component Testing for testable UI patterns.
- The same data logic is duplicated across multiple pages. See Repository Pattern for shared data access layers.
- Testing UI requires mocking networks, stores, and DOM simultaneously. See Unit Testing for isolated test strategies.
Problem
A React component that fetches users, filters by search, sorts by name, paginates results, and renders cards is impossible to test or reuse.
Solution
// model/UserModel.ts
interface User {
id: string;
name: string;
email: string;
role: string;
}
class UserModel {
private users: User[] = [];
setUsers(users: User[]) {
this.users = users;
}
getFilteredUsers(query: string): User[] {
if (!query) return this.users;
const lower = query.toLowerCase();
return this.users.filter(u =>
u.name.toLowerCase().includes(lower) ||
u.email.toLowerCase().includes(lower)
);
}
getSortedUsers(field: keyof User, direction: 'asc' | 'desc'): User[] {
return [...this.users].sort((a, b) => {
const cmp = String(a[field]).localeCompare(String(b[field]));
return direction === 'desc' ? -cmp : cmp;
});
}
}
// controller/UserController.ts
class UserController {
constructor(private model: UserModel) {}
async loadUsers(): Promise<void> {
const res = await fetch('/api/users');
const users = await res.json();
this.model.setUsers(users);
}
search(query: string): User[] {
return this.model.getFilteredUsers(query);
}
sort(field: keyof User, direction: 'asc' | 'desc'): User[] {
return this.model.getSortedUsers(field, direction);
}
}
// view/UserListView.tsx
import { useState, useEffect } from 'react';
function UserListView({ controller }: { controller: UserController }) {
const [users, setUsers] = useState<User[]>([]);
const [query, setQuery] = useState('');
useEffect(() => {
controller.loadUsers().then(() => {
setUsers(controller.search(''));
});
}, []);
const handleSearch = (q: string) => {
setQuery(q);
setUsers(controller.search(q));
};
return (
<div>
<input
type="search"
value={query}
onChange={e => handleSearch(e.target.value)}
placeholder="Search users..."
/>
<ul>
{users.map(u => (
<li key={u.id}>{u.name} — {u.email}</li>
))}
</ul>
</div>
);
}
Vue Example
<!-- view/UserListView.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { UserController, UserModel } from './userMVC';
const model = new UserModel();
const controller = new UserController(model);
const users = ref<User[]>([]);
const query = ref('');
onMounted(async () => {
await controller.loadUsers();
users.value = controller.search('');
});
const handleSearch = (q: string) => {
query.value = q;
users.value = controller.search(q);
};
</script>
<template>
<div>
<input
type="search"
v-model="query"
@input="handleSearch(query)"
placeholder="Search users..."
/>
<ul>
<li v-for="u in users" :key="u.id">{{ u.name }} — {{ u.email }}</li>
</ul>
</div>
</template>
The same Model and Controller work unchanged. Only the View differs, which is the point of separating concerns.
Variations
- MVVM: ViewModel exposes observable properties that the View binds to directly
- MVP: Presenter updates the View imperatively instead of the View observing state
- Flux/Redux: Unidirectional data flow with a central dispatcher replacing the Controller
- MVU (Model-View-Update): Popular in Elm. A pure update function produces a new Model from messages, and the View renders from the current Model. No mutable state.
What Works
- Keep Models pure — no side effects, no DOM references
- Controllers orchestrate but do not know how data is rendered
- Views are thin — receive data and emit events, contain no business rules
How It Works
The Model owns data shape and business rules. It knows how to filter, sort, validate, and relate entities, but it has no knowledge of React, Vue, or the DOM. Keeping Models pure makes them trivial to unit test with plain data.
The Controller owns user intent and coordination. It decides when to fetch data, which Model methods to call, and what to do with the results. The Controller may hold references to services, repositories, or other controllers, but it does not import JSX or templates.
The View owns presentation. It receives data, renders markup, and forwards events. Views stay thin: they call controller methods on user input and re-render when state changes. Framework hooks live here, but only for local UI state such as focus, hover, or animation.
Best Practices
- Keep Models framework-agnostic. They should compile without React or Vue imports.
- Prefer immutable updates inside Models so changes are predictable and cheap to compare.
- Inject dependencies into Controllers instead of constructing them inside. This simplifies testing and swapping implementations.
- Use a dedicated service or repository layer for network calls. Controllers orchestrate services; they should not contain raw fetch boilerplate everywhere.
- Keep Views stateless when possible. Local state is fine for UI-only concerns, but domain state belongs in the Model.
- Test each layer in isolation. Models need only sample data, controllers need mocked Models, and Views need stubbed controllers.
- Document the public interface of each layer so teammates know where to add new behavior.
Common Mistakes
- Putting fetch logic inside the View instead of the Controller or a service layer.
- Mutating Model state directly from a View, bypassing Controller methods.
- Making the Model depend on framework-specific state management or lifecycle hooks.
- Creating anemic Models that are just bags of data with no behavior.
- Allowing Controllers to grow into god objects that handle UI, routing, validation, and persistence.
- Skipping layer tests because “it is easier to test the whole component.”
- Mixing routing logic with business logic in Controllers or Views.
- Ignoring loading, error, and empty states when the Controller fetches data asynchronously.
- Putting validation only in the UI while the Model accepts any value.
- Coupling Controllers to specific View implementations instead of treating them as thin consumers.
Troubleshooting
- Pattern does not fit the problem: re-evaluate the forces (performance, scalability, team size, coupling). A pattern is only appropriate when its trade-offs match your constraints.
- Too many abstractions: if adding a pattern increases complexity without a clear benefit, simplify. Not every module needs a factory, decorator, or strategy.
- Tight coupling after refactoring: check that interfaces are stable and dependencies point inward. Use dependency inversion to break accidental coupling.
- Tests break when the design changes: favor stable contracts over internal structure. Test observable behavior, not private helpers.
- Performance regression from indirection: measure before and after. Layers, decorators, and adapters can add latency; cache or inline hot paths if needed.
Common Production Pitfalls
- Applying the pattern where no abstraction is needed, adding accidental complexity.
- Letting the pattern leak into unrelated modules and blur ownership boundaries.
- Over-engineering the first implementation instead of starting simple and measuring pain.
- Skipping contract tests, so refactors silently break consumers.
- Ignoring failure modes that the pattern does not cover.
- Using the pattern as a default instead of choosing the right tool for the current scale.
- Forgetting to document when to stop using the pattern and what replaces it.
- Missing observability around the pattern’s performance and error propagation.
Frequently Asked Questions
Should Models be plain classes or framework state?
Prefer plain classes or simple data structures. Framework state belongs in Views or state libraries. Pure Models are easier to test and reuse outside the UI layer.
How do I test MVC layers independently?
Test Models with plain data and assertions. Test Controllers with mocked Models and services. Test Views with stubbed controllers and fake user events. Each layer should be testable without the others.
Where does routing belong in MVC?
Routing is a separate concern. Controllers may react to route parameters, but route parsing and navigation belong in a router layer. Keep URL logic out of Models and business logic out of the router.
Can I use MVC with TypeScript?
Yes. TypeScript strengthens the pattern by typing Model fields, Controller interfaces, and View props. Strong types make it obvious when a layer leaks into another.
How do I handle forms and validation?
Validation rules live in the Model. The View calls controller methods on input changes, and the Controller asks the Model whether the data is valid. Error messages flow back to the View through the Controller.
Should the View call the API directly?
No. API calls belong in services or repositories. The View forwards events to the Controller, which coordinates the service call and updates the Model.
How do I share state between unrelated components?
Lift shared state into a higher-level Controller or use a state management library. MVC does not forbid shared stores; it just asks you to keep domain logic out of Views.
Where do side effects belong?
Side effects such as fetch, timers, or storage access belong in services or controllers. Models should remain pure, and Views should avoid side effects beyond rendering.
How do I handle errors?
Controllers catch errors from services and update a Model field or return a result type. Views render the error state. Keep error handling out of raw UI event handlers.
Can MVC work with SSR?
Yes. Models can be populated on the server, Controllers can be instantiated per request, and Views can render from initial props. Just avoid referencing browser-only APIs in Models.
Related Resources
Repository Pattern with TypeScript Generics
Implement a type-safe repository pattern in TypeScript that decouples data access logic from domain services using generics and interfaces.
PatternDecorator Pattern for HTTP Request Pipelines
Use the Decorator pattern to compose cross-cutting concerns like logging, metrics, and retries into HTTP request pipelines without modifying core logic
GuideSoftware Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, and end-to-end tests.
RecipeServer-Side Rendering
Improve performance and SEO with server-side rendering using Next.js, Nuxt, Astro, and other frameworks with hydration strategies.
RecipeWebSockets for Real-Time Communication
Build bidirectional real-time communication with WebSockets, handling connection management, reconnection, and fallbacks.