MVC in Frontend is Dead

Daniel MacΓ‘k - Aug 28 - - Dev Community

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?

. . . . . . . . . . . .
Terabox Video Player