I watched a video from Theo about MVC and how it sucks and it got me thinking. We have been using MVC as the go-to frontend architecture in our company for quite some time. But no one ever questioned if it's the right approach; on the contrary, some people just loved it!
Since it still remains popular, and I didn't find any solid critique on the web, I decided to write one.
MVC what?
By MVC (model-view-controller) I mean the "classic" way of writing frontend apps you might remember from Angular.js (I am old I know).
Short description:
- The View consists of UI components, let's say built with React.
- When user performs an action, like a click, the View delegates to a Controller which does the heavy lifting - it might create a record in DB by calling an API and update the Model as a result.
- When Model, which holds the application state, updates, the View reflects the changes.
Now when you put it like that, it sounds simple enough so why wouldn't we want to use it? Well, after spending years maintaining MVC apps, I am skeptical MVC is worth the effort. Let's go through:
- the main arguments for MVC which I'll mark with ❌ as debunked
- the things MVC generally sucks at, marked with 👎
- comparison to alternatives like Tanstack Query
MVC makes for a clean code ❌
The assumption is that if you divide your code into neat boxes with distinct responsibilities, it makes the whole codebase better. That sounds logical, but does it really? Put shared state aside and have a look at a simple example: A component for showing and creating posts, talking to a backend API. See how it's handled with and without MVC:
MVC
// View
// Has to be made aware of reactivity, therefore `observer` from MobX
import { observer } from 'mobx-react-lite';
export const Posts = observer(({ controller, model }) => {
createEffect(() => {
controller.fetchPosts();
}, []);
function createPost(newPost) {
controller.createPost(newPost);
}
return <>
<PostCreator onCreate={createPost} />
{model.posts.map(p => <article>{p.content}</article>)}
</>
})
// Controller
export class PostsController {
constructor(private model: PostsModel) {...}
async fetchPosts() {
const posts = await fetchPostsApi();
this.model.setPosts(...posts);
}
async createPost(newPost) {
const createdPost = await createPostApi(newPost);
this.model.addPost(createdPost);
}
}
// Model
import { action, makeObservable, observable } from 'mobx';
export class PostsModel {
// has to be reactive, using MobX for that
@observable posts: Post[];
constructor() {
makeObservable(this);
}
@action
setPosts(posts) {
this.posts = posts;
}
@action
addPost(post) {
this.posts = [...this.posts, post];
}
}
And now without MVC.
No MVC
export const Posts = () => {
const [posts, setPosts] = useSignal<Post[]>([]);
createEffect(() => {
async function fetchData() {
const posts = await fetchPostsApi();
setPosts(...posts);
}
fetchData();
}, []);
async function createPost(newPost) {
const createdPost = await createPostApi(newPost);
setPosts([...posts, createdPost]);
}
return <>
<PostCreator onCreate={createPost} />
{model.posts.map(p => <article>{p.content}</article>)}
</>
}
I am past the point of calling a code bad just because it's a bit longer, but in this instance the MVC code is 3x times longer 🚩. The more important question is Did it bring any value? Yes, the component is a little cleaner by not having to deal with the asynchronicity, but the rest of the app is quite wordy. As a result, when you add new features and perform code changes, the commits are accordingly bigger.
As for the No-MVC snippet, I purposefully made it simplistic and didn't use TanStack Query which would make the code even more reasonable.
Organizing Controllers is complex 👎
Over time, your controllers will amass a huge number of dependencies.
Why? First of all, many components might depend on them to handle their events and get access to the Model data.
Secondly, controllers are the glue of the app, meaning they directly mutate models to change the state, call services to perform server mutations and selectively expose Model to View. In other words, they hold the complex business logic.
Inevitably, they become quite heavy and the time comes to split them up. The question is along which lines do you split them:
- per domain
- per view
- per component (Jesus 🙈) ...?
The answer is not obvious and a bad call will lead to sizeable refactorings and rewrites later on.
As if that wasn't complicated enough, you'll find there are dependencies among controllers - parent controller initializes child controller, instructs it to fetch data at a certain point, child needs to notify parent about new developments etc. It gets very complex very fast just to keep the touted promise of "clean, separate boxes" architecture.
MVC doesn't help with Caching 👎
Caching is one of the hardest things to do correctly in CRUD apps and an important problem to solve given today's computation like AI prompts and media generation can be very expensive. Yet, MVC isn't helpful here:
export class PostsController {
constructor(private model: PostsModel) {...}
fetchPosts() {
const posts = await fetchPostsApi();
this.model.setPosts(...posts);
}
}
If 3 separate components call fetchPosts()
, the fetch happens 3 times although logically just 1 fetch would have sufficed. You probably don't want that, so how do you optimize? There are basically 2 options with MVC:
Ad-hoc caching
Write code like this:
fetchPosts() {
if (this.model.posts?.length > 0) {
return;
}
const posts = await fetchPostsApi();
this.model.setPosts(...posts);
}
Which is brittle, not centrally handled and very limited in its capabilities. If you'd like stale timeouts, cache evictions etc., you'd have to write it yourself or bring a library onboard which handles it. That doesn't sound so bad, but given MVC separates the Model from the other layers, you'd still need a lot of code for the V and C to have some say in the M's caching state.
Data fetching caching
The other option is caching on the data fetching layer, either using Service Worker (which isn't very flexible solution) or some fetch-caching lib. Same as with the ad-hoc caching above however, the problem remains that the fetch calls and the models are disconnected and the controllers need to keep them in sync at all times.
MVC makes for a Better Testing ❌
The argument tends to be two-fold here:
- Because the app layers are so well defined, it makes the (unit) testing cleaner and therefore easier.
- Since View is devoid of its own state, one should hardly need the View layer to be unit and integration tested. That saves testing complexity by removing the UI lib from the equation.
Let's see how it is in reality.
It makes unit testing harder
The first argument is a false promise. Since the View is dull (remember, it's the Controller calling the shots) and the Model usually doesn't contain much business logic (Controllers do), there indeed isn't much to unit test in the View.
However, that means all weight is on Controllers, with their many dependencies and complex logic. If you want to unit test them, you'll have a lot of mocking to do which in turn makes testing very labourious and the testing conditions so unrealistic that it's borderline useless.
The other option is to test Controllers only in integration tests where the bulk of the app is initialized, but that requires a proper set up and upfront planning to architect the app accordingly.
No you can't just forget about UI in tests
The second argument about leaving out the View from tests is even bigger Fata Morgana. Even if the View layer was "dull", there is always some logic in there whether you want it or not - handling user events, conditional display of content, UI updates through state changes, reactivity, you name it. And there can be bugs in all of these, possibly making the UI unusable.
That only shows that leaving out UI unit tests is a gamble, and E2E can't fully offset it.
But then I need to include React in my tests
So? It will bring you and your tests closer to your users. It's a breeze to test with the Testing Library and given how many starter tools we have nowadays, it's no problem to include the UI framework in your tests as well.
I absolutely love this notion from a random person (don't remember where I saw it :/) on the internet on the topic of performance:
If the added React layer significantly increases the tests execution time, it's not the tests that are to blame, it's your bloated UI.
State is in the Model 👎
Separating state away from components is completely arbitrary. Don't get me wrong, shared state of course should be made accessible to components that need it. But if a state concerns just one part of the view, it should be co-located with its component, simple as that.
The reason is that it's much simpler to grasp how a component and its state influence each other if the two are in a direct relationship, even in the same file.
In MVC, the View has to trust the Controller to give it the right state and handle the state mutations correctly since it has no direct influence on the Model itself. And of course you need to inspect at least 3 files instead of 1 to understand how components and state interact. This becomes even more evident when you throw state management solution like Redux into the mix.
State should be in the Model ONLY ❌
This is such a strict measure and no wonder @markdalgleish apologized for enforcing it in the distant past through a Lint rule. I am firmly convinced putting ALL state to central places and interacting with it only through controllers leads to bloated, hard to understand and hard to change code.
You can easily switch to different UI framework ❌
Emphasis on the word framework. Frameworks are usually opinionated about the app architecture, and use their own kinds of primitives for building apps. Take Angular's DI and RxJS, or Solid's Signals as an example.
If you use MVC and want to do a rewrite, say, from React -> Angular, well first why on Earth would you do that, and second, Angular is so different from React that you'd probably have to do a large rewrite. Such a rewrite would ripple through all the parts of your MVC architecture, even wannabe framework-agnostic controllers.
Is the touted case for an easy rewrite even valid?
It's questionable if a rewrite of such parameters where you only swap the UI framework and leave the rest largely intact isn't just a chimera. The most common reasons for a rewrite are in my experience:
- The need for a refresh of a legacy UI, providing new and updated features. Here, it's usually easier to start from scratch due to legacy baggage.
- Rewrite out of frustration with unmaintainable code base, leading to a complete overhaul of the app's architecture.
Neither would leave the MVC architecture intact and it turns this supposed benefit on its head.
So what's the better alternative? ✅
I am tempted to say anything else than MVC, but I don't want to overreach. I'd say a very solid approach is to use the already mentioned TanStack Query, since it solves most if not all the discussed problems. Let's see some code:
import { useQuery } from '@tanstack/react-query'
export const Posts = () => {
const { isPending, refetch, data: posts } = useQuery({
queryKey: ['posts'],
queryFn: fetchPostsApi,
})
async function createPost(newPost) {
await createPostApi(newPost);
refetch();
}
return <>
<PostCreator onCreate={createPost} />
{posts.map(p => <article>{p.content}</article>)}
</>
}
So you can immediately see that instead of interacting with a controller or fetching data directly, I define a Query
which does that for me. When I create a new post, there is a convenient refetch
method that performs the query again.
Now there is a lot to talk about regarding TanStack Query but I'll concentrate only on the points discussed above.
Code being clean ✅
I say the code is much cleaner by the mere fact that we got rid of Controller completely and in this instance of the Model as well. Of course if you need to extract business logic or state and make it shared, do that, but there is no reason to follow the rigid MVC structure.
And of course, as a bonus, there is much less code.
Organizing Controllers ✅
Not an issue anymore, Controllers are gone.
Caching ✅
This is a staple feature of TanStack Query. All requests are recorded, cached and deduplicated automatically as needed. You can set cache expiration time, invalidate, refetch and much more, very easily, just check their docs.
Testing ✅
Testing is pretty easy I'd say, as only 2 steps are required in the particular architecture I am using:
const fetchPostsSpy = vi.spyOn(postsApi, 'fetchPostsApi');
render(() => (
<QueryClientProvider client={queryClient}>
<Posts />
Mocking the API to provide dummy data and providing the QueryClient
.
You only test what the particular component needs, nothing more, no big chunk like with MVC Controllers.
State placement and syncing ✅
State is co-located with the components through the Query
and synced automatically with the data (state) provided by the backend, all in one objects.
This is of course not to say that you should have all your business logic in your components or that all state should be inside components as well. On the contrary, it absolutely makes sense to extract this where needed. My point is however that this should be done with judgement in the simplest way possible rather than blindly follow MVC everywhere.
Wrap up
I am pretty certain the case for MVC in the Frontend is weak and there is no good reason to use it, as there are much superior alternatives.
I'd love to know what you think, do you like using it, or did you wave it good bye and never looked back?
Top comments (2)
Interesting arguments!
I like that you mentioned the fact that in MVC paradigm, controllers are basically the only thing to test, and since they accumulate all the logic inside, it is indeed no change compared to testing components directly - so true!
Now, I wonder if you worked with a large-ish codebase using your proposed solution.
I did.
And it was a mess. TanStack Query (or alternatives) are amazing if you need one piece of data per component that just renders and that's it. But often times you need to post-process: construct list filters or table sections, and forward (processed) data to children. Eventually, your children are 5-10 components deep and you are forwarding like crazy. Then you realize you could use context, but realistically, using MobX instead of Redux or native context is much better choice. You landed on MV pattern suddenly. But it doesn't stop there. Your component accumulated so much logic inside that you'd like to split it, but by which lines - "per domain, per view, per component"? Testing becomes mess at this point because each component needs like 10 props. So your obvious choice is to separate logic from the view and here you go, in the MVC world again.
I might be wrong, I worked with one project with this approach, so I will be very happy to be proven wrong. I just need to see a scaled app that still works well with (whatever) query lib out there.
I agree that MVC might be over-engineering for most of the basic react apps out there. But I believe MVC is a firm ground once your app gets bigger.
Thanks for the feedback! You make some interesting points and I got to admit I haven't used TanStack in a large app yet, but apparently many people have. But some things can be considered even without it:
I think it's pretty easy in scope of 1 component to combine multiple queries and even do post processing on them if needed in a functional way, or do you see a problem there?
This is a common problem with any component-based framework like React. You can use MVC to solve it, but it's definitely not required. State can be shared easily without MVC and especially without controllers :).
This is very contextual. Does the component represent multiple UI units? Split it into individual ones. Has it state that needs to be shared? Extract it! Does it contain a lot of state that could be condensed into fewer domains? Use hooks or similar to abstract away! MVC doesn't help here because splitting up controllers isn't as obvious.
My experience is the opposite actually as I find MVC pretty hard to scale and adapt to changes. That said, you can still reap the best of the 2 worlds with RTK Query which is like TanStack Query but using Redux for its cache if you prefer so, therefore keeping state central, but I'd prefer to avoid Redux if possible.