Every frontend framework answers one question: when the user clicks something, who owns that change? Here’s how MVC, MVP and MVVM answered it, why the industry moved to one-way data flow, and where each old role lives today, with real code from Backbone, Angular and Ember apps.
See, every frontend framework is basically answering one thing: when the user clicks something, who owns that change, and how does the screen come to know?
MVC, MVP and MVVM are three answers to that question. One-way data flow and signals are the newer ones. Understand the patterns, and any new framework looks less like magic and more like a set of choices about where state lives.
This post walks through each pattern: where it came from, what it looks like in real code, and where it hurts. The examples come from small real apps in our migratex-examples repo, built with jQuery, Backbone, Angular and Ember.
Why Patterns Exist at All
Strip away the framework names and every interactive screen has to answer three questions:
- Who owns the state? The todos, the selected tab, the form input. Some code has to hold it.
- Who is allowed to change it? One place, or anyone with a reference to it?
-
How does the screen find out? Does someone call
render(), does a binding engine notice, or does the view re-run?
Every pattern below is a different set of answers to these three. Keep them in mind and the comparisons become much easier.
Life without a pattern. No pattern means pure jugaad. Our jQuery tip calculator kept three loose variables (total, people, tipPerc) that four event handlers changed, and each handler pushed results into the page with .html(). The variables and the screen kept drifting apart. The custom tip was never used, Reset left the old tip in memory, and some inputs showed NaN. None of these were hard bugs. That’s just what happens when state has no clear owner.
MVC: The OG Pattern
Trygve Reenskaug came up with Model-View-Controller at Xerox PARC in 1979, while working on Smalltalk. Three roles:
- Model: the data and its rules. Knows nothing about screens.
- View: draws the model, and redraws when it changes.
- Controller: turns user input into calls on the model.
The key idea is that the model announces “I changed”, and every view watching it redraws. That’s the Observer pattern, and almost every UI framework since has some version of it.
Server frameworks like Rails and Django later borrowed the name for something different: a controller that handles a request, renders a template and is done. Nothing watches anything, because the page is thrown away after every request. Frontend frameworks inherited both meanings, which is why “MVC” in a JavaScript codebase can mean almost anything. When someone says their app is MVC, it’s worth asking which kind.
Backbone in the browser
Backbone.js (2010) was the poster child for client-side MVC. Our Backbone GitHub viewer has models that fire change events, views that render templates, and a controller that adds views to the page. Here’s a trimmed repoView.js:
var RepoView = BaseView.extend({
initialize: function () {
this.pageData = { isPhone: Globals.controller.isPhone, repos: this.options.repos };
// "This didnt work for some reason. So using on method."
Globals.events.on('page:destroy', this.close, this);
this.render();
},
render: function () {
this.$el.html(this.template(this.pageData));
return this;
}
});
A big upgrade from loose jQuery. Data lives in models, HTML lives in templates, and each view owns one piece of the page. For small and medium apps, it works well.
Where MVC hurts
The same short file shows the problems client-side MVC ran into as apps grew:
- Subscriptions are managed by hand. Forget to unsubscribe from the global event bus and you get zombie views that still react to events. The comment shows it already confused the original dev once.
-
Everything knows about everything. The view reads
Globals.controllerdirectly. Over time, changing one file means reading five. -
Rendering is manual.
render()replaces the whole element, so scroll position, focus and half-typed input get lost.
MVP: The Passive View
Model-View-Presenter came out of Taligent in the 1990s and was later popularised by Martin Fowler’s “Passive View”. It fixes MVC’s sideways reaching by cutting the view off from the model completely. The view forwards events to a presenter and exposes simple methods. The presenter makes every decision:
class RepoPresenter {
constructor(view, api) { this.view = view; this.api = api; }
async onUserSelected(user) {
this.view.showLoading();
this.view.setRepos(await this.api.fetchRepos(user));
}
}
The big win is testing. You can test the presenter with a fake view, no DOM and no browser, which is why MVP stayed popular in Android and desktop apps for years. The cost is glue code: every field needs a setter and every button needs a forwarder. On a busy screen, that’s dozens of tiny methods that only copy values around. MVVM’s big idea was to make the framework write that glue for you.
MVVM: Bindings Do the Glue
John Gossman at Microsoft named Model-View-ViewModel in 2005. A ViewModel is a plain object holding everything the screen needs: state, derived values and commands. The view declares bindings to it, and a binding engine keeps the two in sync, both ways. No render(), no setters.
MVVM on the web
Knockout, AngularJS and Ember all went this route, and modern Angular still keeps the shape. In our Angular todo app, the AppComponent class is the ViewModel, with zero DOM code:
export class AppComponent {
todoList: ToDo[] = [];
title = '';
constructor(private localStorageService: LocalStorageService<ToDo>) {}
handleItemComplete(id: string) {
const item = this.todoList.find(i => i.id === id);
this.todoList = this.todoList.filter(i => i.id !== id);
if (item) this.completeList = [{ ...item, isDone: true }, ...this.completeList];
this.updateLocalStorage();
}
}
The template only declares bindings. [(ngModel)] is two-way: typing updates title, and changing title in code updates the input.
<input name="title" [(ngModel)]="title" />
<todo-item *ngFor="let item of todoList"
[title]="item.title"
(onComplete)="handleItemComplete($event)"></todo-item>
Notice the split. The injected LocalStorageService is the model layer. The class is the ViewModel, testable by calling methods and checking fields. The template is the view, with no logic beyond bindings.
Ember Octane took it further. Our Ember blogging app marks state with @tracked and derives values with plain getters. Ember knows exactly which values each getter read, so only the affected parts of the page update. Remember this one, it comes back later as signals.
Where MVVM hurts
- “Who set this value?” When data changes from both sides, tracing a wrong value becomes a proper headache.
- Performance cliffs. AngularJS re-checked every binding until nothing changed. With thousands of bindings, pages crawled.
- Hidden logic in templates. Binding expressions are code, but you can’t easily put a breakpoint in them.
-
God objects. The ViewModel is a handy place to dump everything. Our
AppComponentalready owns the form, both lists and persistence.
Side by Side
| MVC | MVP | MVVM | One-way flow | |
|---|---|---|---|---|
| Who owns UI state | Model and view, split | Presenter | ViewModel | One owner (component, hook, store) |
| How the view updates | Model events + render()
|
Presenter calls setters | Binding engine | Re-render from state |
| Direction of data | Many directions | Presenter ↔ view | Both ways | Down; events go up |
| Usual pain | Zombie views | Glue code | “Who set this?” | Prop drilling, effect misuse |
| Examples | Backbone | GWT, classic Android | Knockout, AngularJS, Ember | React, Elm, Redux |
One-Way Data Flow
In 2014, Facebook’s Flux argued that the real problem was data moving in both directions. One click could set off a chain of model and view updates that nobody could predict. The fix: state goes down into the UI, events go back up, and only the owner of the state can change it. Most modern frameworks follow some version of this, often written as UI = f(state).
Where the old roles live now
| Old role | Where it lives now | Example (React) |
|---|---|---|
| View + ViewModel | One component, split by feature | A component function |
| ViewModel logic | A reusable state unit (hook, composable, store) | A custom hook like useTodos()
|
| Model (server data) | A data-fetching and caching layer | TanStack Query |
| Controller + router | A route table | React Router |
| Two-way binding | Value down, change event up |
value + onChange
|
Note the first row. MVC and MVVM split code by layer: all models here, all views there. Components split code by feature, which is easier to change, because most changes touch one feature, not one layer.
Same idea, different frameworks. Vue has composables, Svelte has stores, Angular has services and signals. Different names, same idea. React is a handy example because it’s the most explicit:
// MVVM (Angular): mutate, the binding engine notices
this.todoList.push(newTodo);
// One-way flow (React): replace, and the UI re-renders
setTodoList([...todoList, newTodo]);
That one line is the whole shift. In MVVM, anything with a reference can write to the state. In one-way flow, every change goes through one door, so when a value is wrong, there’s only one place to look.
It also changes how you think about time. A React state update doesn’t apply until the next render, so reading the state right after setting it gives you the old value. The fix is a simple habit: work out the next value first, then use that same value everywhere.
Signals
Solid, Vue’s ref, Angular signals and Ember’s @tracked all track exactly which value each piece of UI reads, so only that part updates. No full re-render, no dirty checking. The difference from old MVVM is discipline: a signal is written by one owner and read by many, so data still flows one way. Best of both, basically. So today’s debate is less “MVC vs MVVM” and more “how precisely do we track changes, and do writes flow one way?”
Common Mistakes
-
Storing derived values. If
totalcomes frombillandpeople, calculate it every time. Most of the jQuery calculator’s bugs came from stored values going stale. - Two sources of truth. The same data in a store and in a component will disagree sooner or later.
- Fetching data inside the view. Views that call APIs directly are hard to test and reuse. Keep server data in its own layer.
- Global event buses. They hide who talks to whom. Backbone apps learned this the hard way.
- The god object. Split by feature before one class owns half the app.
How to Choose Today
In 2026, you mostly pick a framework, and the framework picks the pattern. But you still choose how to use it:
- A page with a few interactions: plain JavaScript is fine. Just give each piece of state one owner.
-
A form-heavy internal app: two-way binding (Angular forms, Vue
v-model) saves real time. - A large app with many teams: one-way flow, state split by feature, server data in its own layer. Predictability matters more than saving keystrokes.
- Lots of fast-changing values (dashboards, editors): look at signals.
Bottom line: patterns come and go, but the habits they taught stay useful in any framework:
- Give every piece of state one owner, whatever your framework calls it.
- Keep the view dumb and the logic testable without the DOM.
- Keep data fetching out of the view. A separate layer for server data, a plain module for storage.
- Derive values, don’t sync them. Half the jQuery bugs came from copying values around.
- Make writes one-way and visible. Two-way binding feels magical till you have to debug it.
Our Migration Plugins
Knowing the patterns is one thing. Moving a real app from one to another is where it gets hard. You have to find every model, view, controller and binding, decide where each one lives in the new world, and prove the app still behaves the same, all while the old app keeps shipping features.
We’ve packaged how we do this as free, open-source plugins for Claude Code. There’s one per starting point, each taking an app to React, the one-way-flow example used above:
| Plugin | Starts from | Pattern it moves away from |
|---|---|---|
backbone-to-react |
Backbone.js, Marionette, jQuery templates | Classic client-side MVC |
angular-to-react |
Angular 2+ and AngularJS 1.x | MVVM with two-way binding |
ember-to-react |
Ember Classic and Octane | MVVM with tracked state |
jquery-to-react |
jQuery apps, plugins and jQuery UI | No pattern: state synced by hand |
vanillajs-to-react |
Plain HTML, CSS and JavaScript | No pattern: direct DOM updates |
Each plugin comes with the same set of parts: a migration skill, a /<plugin>:plan command that writes a migration plan without touching your code, a read-only inventory agent that maps the old app, a parity-reviewer agent that compares each migrated piece with its original, and a bundled Context7 server for up-to-date library docs. To install one in Claude Code:
/plugin marketplace add cobuild-tech/cbx-plugins
/plugin install angular-to-react@cbx-plugins
They also work in Cursor, in VS Code with GitHub Copilot, and in the GitHub Copilot CLI. Progress is kept in a .migration/ folder in your repo, so a migration can carry on across sessions and teammates. The source and setup steps are at cobuild-tech/cbx-plugins, and issues are welcome.
About MigrateX
MigrateX is our platform and service for moving software, data and infrastructure from one technology to another. It combines automation with experienced engineers. The automation does the repetitive work quickly, and the engineers make the tricky decisions, like where state should live once the old pattern is gone.
A MigrateX project usually runs like this:
- Assessment. We map every model, view, controller, binding and shared variable in your app, so you see where the work is before anyone agrees on a timeline.
- Plan. With your team, we decide what moves first, what gets replaced, and what can simply be deleted.
- Migration, step by step. Automation handles the repetitive parts. Our engineers handle state, data flow and the code that connects old and new.
- Proof that it works. The same tests run against both versions. A piece only counts as moved when it passes on both.
- Handover. We remove the temporary code and leave your team with a codebase they understand and own.
Your app keeps running and shipping features the whole time, so there’s no big launch day. The examples in this post come from our migratex-examples repo, which has each small app next to its migrated version.
Got an app still running on Backbone, AngularJS, Ember or jQuery? Tell us a little about it through Contact Us, and we’ll start with a free chat about where the work is likely to be.
Further reading
- Trygve Reenskaug. MVC: Xerox PARC 1978–79. https://folk.universitetetioslo.no/trygver/themes/mvc/mvc-index.html
- Martin Fowler. GUI Architectures. https://martinfowler.com/eaaDev/uiArchs.html
- John Gossman. Introduction to Model/View/ViewModel pattern for building WPF apps. https://learn.microsoft.com/en-us/archive/blogs/johngossman/introduction-to-modelviewviewmodel-pattern-for-building-wpf-apps
- Meta. Flux: In-Depth Overview. https://facebookarchive.github.io/flux/docs/in-depth-overview
Source code
- CobuildX AI. cbx-plugins: Claude Code plugins for Backbone, Angular, Ember, jQuery and Vanilla JS to React migrations. https://github.com/cobuild-tech/cbx-plugins
- CobuildX AI. migratex-examples: small jQuery, Backbone, Angular, Ember and Vanilla JS apps and their migrated versions. https://github.com/cobuild-tech/migratex-examples
Originally published on the CobuildX blog.





