DEV Community

Cover image for MVC in Frontend is Dead
Daniel Macák
Daniel Macák

Posted on • Updated on

MVC in Frontend is Dead

I watched a video from Theo on the topic of MVC and how it sucks the other day and it got me thinking. We were using MVC quite a lot as the go-to frontend architecture in our company but never really questioned if it's the right approach; on the contrary, some people just loved it!

The fact that it to this day remains somewhat popular, at least with some of my friends and colleagues, and I didn't find any critique on this topic on the web, I decided to write one.

MVC what?

By MVC (model-view-controller) I mean the kinda "classic" way of writing frontend apps some still might remember from Angular.js (I am getting old I know).

MVC overview

Short description:

  1. The View consists of UI components, let's say built with React.
  2. 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.
  3. 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, and
  • the things I just found to be bad about MVC along the way, marked with 👎
  • and see why they just don't compare to alternatives like Tanstack Query

MVC makes for a clean code ❌

This notion rests on the assumption that if you divide your code into neat boxes it just makes the whole codebase better. But does it really? Put complex logic and shared state aside and just have a look at a simple example of having a component for showing and creating posts, talking to the backend API, and 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];
  }
}
Enter fullscreen mode Exit fullscreen mode

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>)}
  </>
}
Enter fullscreen mode Exit fullscreen mode

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. First of all, many components might depend on them to handle their interactions. Secondly, controllers are the glue of the app but the place for business logic as well, meaning they change bunch of models and call multitude of services to work with data.

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 🙈) ...?

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

If 3 components call fetchPosts(), it's gonna fetch 3 times if you don't handle this somehow. You basically have 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);
  }
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. 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>)}
  </>
}
Enter fullscreen mode Exit fullscreen mode

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 />
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
senky profile image
Jakub Senko

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.

Collapse
 
daelmaak profile image
Daniel Macák

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:

TanStack Query (or alternatives) are amazing if you need one piece of data per component that just renders and that's 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?

Eventually, your children are 5-10 components deep and you are forwarding like crazy.

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 :).

Your component accumulated so much logic inside that you'd like to split it, but by which lines - "per domain, per view, per component"?

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.

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.

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.