I watched a video from Theo on MVC and how it sucks and it got me thinking. We have used MVC as the go-to frontend architecture in our company quite often. But no one ever questioned if it's the right approach; on the contrary, some people just loved it!
Since it still remains popular, at least with some of my colleagues, 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 it makes the whole codebase better. 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];
}
}
Quite wordy arrangement, let's see how it looks 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 busy just to perform 2 very basic tasks. And obviously when you add new features or perform code changes, the commits are accordingly bigger.
On the other hand, I made the No MVC snippet purposefully 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 various interactions.
Secondly, controllers are the glue of the app, meaning they directly change bunch of models, call multitude of services and selectively expose Model to View. This implies they are the ones holding complex business logic.
Eventually they become too heavy and it's time 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 later on.
After the split, you'll find there are dependencies among them - parent controller initializes child controller, instructs it to fetch data, child needs to notify parent etc. It gets very complex very fast just to maintain this "separate boxes" illusion.
MVC doesn't help with Caching 👎
Caching is one of the hardest things to do correctly 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 components call fetchPosts()
, the fetch happens 3 times. You probably don't want that, so 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, or:
Data fetching caching
Cache on the data fetching layer, either using Service Worker (which isn't very flexible solution) or some caching lib. But the problem remains that the calls and the models are disconnected and the controllers need to keep them in sync.
Both options are lacking, needless to say.
MVC makes for a Better Testing ❌
The argument tends to be two-fold here - that because the app layers are so well defined, it makes the (unit) testing easier and cleaner, and secondly one shouldn't need the View layer to be unit and integration tested at all, therefore avoiding testing complexity and improving the test performance.
It makes unit testing harder
The first argument is completely bogus. Since the View is dull (remember, it's the Controller calling the shots) and the Model usually doesn't contain much logic (Controllers do) there is not much to unit test besides the Controllers. But the Controllers are soo heavy that unit testing them would mean mocking virtually everything and would be devoid of value.
No you can't just forget about UI in tests
The second argument about leaving out the View from tests is actually harmful. Even if the View layer is dull, there is always some logic in there - handling events, conditional display of content - and there can be bugs, eg. lost reactivity leading to out of sync UI. All of this better should be tested at least to some degree, otherwise one leaves a gaping hole in her test suite.
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 👎
The separation of state away from the components feels completely arbitrary. Either the state is shared, and then it makes sense to extract it into more central location, or it's not and it should be co-located with the component, simple as that.
The reason is that it's much simpler to grasp how the state is connected with the component when it's a direct relationship rather than with controller as the man-in-the-middle which the component has to trust will manage the state correctly. This is even more evident when you throw state management into the mix, like RxJS or Redux.
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 and hard to understand code; but even if you are convinced that most state should be centrally located, there is no even remotely good reason to put UI specific things in there too.
You can easily switch to different UI framework ❌
Emphasis on the work framework. Frameworks tend to be more or less opinionated about the architecture and are based on different kinds of primitives.
If you use MVC and want to do a rewrite from React -> Angular, well first why on Earth would you do that, and second, Angular is built completely differently, uses it's own DI system, and its primitives like Signals or RxJS are completely different to React's. Such rewrite would ripple through all the parts of your MVC, even controllers.
Or if you did a rewrite into eg. Solid, you'd have to respect the fact that all reactive properties would have to be created inside the root reactive context, plus the Signals are again completely different to what exists in React ecosystem. The point is, the odds of the easy UI framework swap are pretty low.
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 changes and new 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.