Is MVVM Necessary for Developing Apps with SwiftUI?

An Introduction to MV Pattern

Β·

12 min read

Is MVVM Necessary for Developing Apps with SwiftUI?

Hi, everyone. In this article, I want to share my opinion about the question above. Before starting, I would like to clarify that my goal is to show a different perspective from what most people think. No perfect pattern or architecture magically fits your needs; there are always trade-offs. It's important to explore alternatives to make a good decision when developing software.

The "default" pattern

Since I began using SwiftUI in 2019, there has been an ongoing debate about what should be considered the "official" architecture or pattern for starting projects with it. This concern is understandable, as many developers have transitioned from the imperative paradigm of UIKit to a declarative approach that fundamentally alters how we design and implement our applications.

Over time, the community has largely adopted MVVM as the "go-to" pattern/architecture for working in SwiftUI. MVVM has been a great pattern since the early 2000s to decouple the view's logic into a single place (ViewModel) that can be easily testable.

However, MVVM was created when reactive programming had not yet gained broad acceptance, and integrating it with a modern framework such as SwiftUI often requires extra effort. When something like that happens, it indicates something is wrong.

Here, I can summarize some of the most common issues using MVVM with SwiftUI:

  1. View Models are closely tied to views, which can lead to the "massive view model" problem if not carefully managed.

  2. View Models require the implementation of Observable Objects, which frequently results in the underutilization of @State and @Binding in many situations.

  3. View models are not compatible with @Environment and @EnvironmentObject, both fundamental elements within the SwiftUI State Management.

  4. View models frequently become "essential" to enable testing of view logic, and to avoid the creation of "Smart Views", views overloaded with logic and operations, which are complicated to unit test.

  5. Last but not least, MVVM is not a "silver bullet"; it won't suit many problems out there. Assuming a "default" pattern/architecture merely for the sake of it is not a prudent approach to problem-solving. These issues, and more, have motivated the community to explore other alternatives, such as TCA.

This raises the question: Are View Models truly necessary in SwiftUI?

I've had the same question for many years, and according to many references (that you can find at the end of this article), the answer is no. Developing applications in SwiftUI without relying on view models is completely feasible.

To demonstrate this, I've developed an OnlineStore app from scratch using only the SwiftUI framework and its capabilities, bypassing the need for any view models and without requiring architecture libraries (such as TCA, RIB, ReSwift, etc.). This approach has informally been called "Model-View" or MV for short.

The original implementation of this OnlineStore app utilized The Composable Architecture (TCA). For more details about this app using TCA, visit this repo.

Now, I anticipate that some of you might be wondering:

  • "Hey, Pitt, But what about adding logic into views? That's a bad practice!"

  • "How do we approach unit testing without View Models?"

  • "I have an app built with MVVM. Should I discard it and start over?"

No need to worry; continue reading, and we will address all these points πŸ˜‰!

I've referred to MV and MVVM as a pattern/architecture above because it depends on whom you ask; you may receive one answer or another. For simplicity, from now on, I will refer to MV and MVVM both as patterns.

What is the MV pattern?

Let's first explain what the MV pattern does. As I explained above, MV stands for "Model-View" (without ViewModels). Here's a breakdown of each component:

  • Model: This layer is about the data and the underlying business logic. It encapsulates everything from data retrieval and storage to processing and validation of business rules.

  • View: This component presents the data displayed to the user. The user interface (UI) renders the data on the screen, including all the visual elements like text, buttons, and images that users interact with.

This is the most basic code using the MV pattern:

struct ContentView: View {
    @State private var counter = 0 // Model

    var body: some View { // View
        Button {
            counter += 1
        } label: {
            Text("\(counter)")
        }
    }
}

Does this sound familiar? Yes, it's essentially what we call SwiftUI "Vanilla"!

"MV" is a term embraced by the community. Apple has not officially designated a name for this pattern.

To elaborate, the MV pattern in SwiftUI leverages the framework's property wrappers to manage the model's state (@State, @Environment, @Bindable) and communicates updates to the view for display on the screen.

To explore State Management and Observation in SwiftUI further, take a look at this article.

This diagram represents how MV Pattern works in my OnlineStore app:

The Stores (Environment Objects) manage and process the data (green circles) to report the changes to the view.

Now compare it to MVVM:

Notice how we are forced to create a ViewModel per view in MVVM. That makes sense because we want to encapsulate the view's logic outside the view. However, the problem is that the views will not always need that.

You may see that View4 doesn't need any dependency or observable object in MV, but in MVVM, we have declared its own ViewModel even when technically we don't need it.

On the other hand, View2 and View5 observe data changes from CartStore in MV, and it manages whatever data is needed to store cart information. However, in MVVM, both have their own ViewModel that should access the model in isolation, even when they use the same information. We also need to mention that ViewModel cannot use @Enviroment nor @EnvironmentObject, making SwiftUI difficult to use.

The example above is relatively small, but you can imagine that the more views we have, the more extra ViewModels we need to maintain. MV is simple but very effective with the declarative approach that SwiftUI provides.

Now, the question is, what will happen if the view requires managing some internal logic that ViewModel used to handle?

Simply, for those cases, @State will be a perfect fit for that!

Now, let's bring back this question: "Hey, Pitt, But what about adding logic into views? That's a bad practice!"

You are correct! Keeping logic inside a view is normally a back practice. However, there's a plot twist: SwiftUI views are not views! 🀯

I'm not drunk, keep reading please!

The "Views" are not Views

When we use the word β€œView”, we normally associate it with the UI that the user will see on the screen, but in SwiftUI, this is different. View protocol is one of the core pieces of this framework, and when we see code like this:

import SwiftUI

struct ContentView: View {
    var body: some View {
        // More code …
    }
}

we immediately say that ContentView is the view to be rendered on the screen.

However, this is not 100% true. It turns out that ContentView struct is not a resulting user interface but a view definition.

In this session from WWDC19, Apple explains this concept!

I like to use analogies to explain things, so this is equivalent to using your favorite food delivery app to order a bagel:

Yummy!

You describe how you want your bagel by selecting the ingredients and additional toppings. This is the same for views in SwiftUI. Unlike UIKit, when you have to build your views using UIView (that is literally a UI) piece by piece, SwiftUI describes what should be rendered, but the framework will decide how this will be done.

As you remember, SwiftUI was designed to work on multiple Apple OS such as iOS, macOS, watchOS, tvOS, and even visionOS.

Take a look at this very simple code that, after pressing a button, shows an alert:

struct ContentView: View {
    @State var showAlert = false

    var body: some View {
        Button {
            showAlert = true
        } label: {
            Text("Press Me!")
        }
        .padding()
        .alert("swiftandtips.com", isPresented: $showAlert) {
            Text("OK")
        }
    }
}

Look how the alert is rendered differently depending on the OS:

That's the point! Your View defines what the view should contain, but not how it will look.

In fact, that's why SwiftUI was designed to use property wrappers to manage the state inside the views. If you think about it, a SwiftUI View, is actually equivalent to a ViewModel in MVVM! 🀯

This is key to understanding why using ViewModels from MVVM is redundant: You create a ViewModel inside a ViewModel πŸ”₯.

Now, Let's bring up another question from above: "How do we approach unit testing without View Models?"

One of the reasons to use ViewModels is to decouple View's logic to unit test it easily. If a SwiftUI View is technically a "ViewModel", how can we test it? πŸ€”

Let's find out!

Testing views with MV Pattern and SwiftUI

Testing is a huge topic that deserves its own article (or many), so I will try to be brief here.

Some people see unit tests as the ultimate way of testing apps. Don't get me wrong; unit testing is a powerful tool to validate and ensure your app has enough quality to be delivered to your users. However, it's not the only one, and it doesn't replace other kinds of testing, such as integration tests, end-to-end tests, etc.

To clarify:

  • Unit test: Test just a small piece of your code (functions, classes, etc.) in isolation without depending on any external component, service, or UI.

  • Integration test: This test demonstrates how different app components (database, network, external APIs, etc.) work together without requiring UI interaction.

  • End-to-End Test (aka UI tests): As the name suggests, this test reviews the flow of a user interaction, such as pressing a button to send an order to a Web API.

You can learn more about the different ways of testing from this article.

Answering the question above, How can we "unit-test" our views then? Easy, let's scroll down in any SwiftUI View file, and you will find the way to test it:

#Preview {
    ContentView()
}

I promise you that I'm not drunk! Apple promotes this way of testing views through previews. Watch this video from WWDC23.

Yes, we can use previews to test your View's logic. Previews are designed not only to render your view in a simulator, but also to validate your view in isolation. And for those cases that require a broader scope, UI tests can be a better fit to review the end-to-end flow.

Just remember that we need to test what we REALLY need. Overtesting components to reach % of coverage just for the sake of it is not a good practice!

We want to make tests that review the behavior of our app under some edge cases; otherwise, it is not useful.

Take a look at my OnlineStore app again. I'm mainly using two environment objects to manage the app state: ProductStore and CartStore.

ProductStore fetches products from a web API. Do we need to test it?

import Foundation

@Observable
class ProductStore {
    enum LoadingState {...}

    private var products: [Product]
    private let apiClient: APIClient
    var loadingState = LoadingState.notStarted

    init(apiClient: APIClient = .live) {...}

    @MainActor
    func fetchProducts() async {
        do {
            loadingState = .loading
            products = try await apiClient.fetchProducts()
            loadingState = if products.isEmpty {
                .empty
            } else {
                .loaded(result: products)
            }
        } catch {
            loadingState = .error(message: error.localizedDescription)
        }
    }

}

I mean, yes, we can, but how much value did we get from that testing?

Since the code is not executing a complex operation but just a web API call, there's no point in testing it in isolation. What we can do instead is to test it through the view:

#Preview {
    ProductList()
        .environment(ProductStore(apiClient: .test))
        .environment(CartStore())
}

In this example, we are testing ProductList view from a preview and passing to ProductStore a test apiClient configuration to always return a specific result.

I will definitely create dedicated articles about testing on SwiftUI coming soon.

However, for CartStore, I decided to write some unit tests because we are making calculations about the quantity of products to show in the Cart List. This is a great example of how unit tests help us to keep complex operations covered.

As you can see, Testing with the MV pattern in SwiftUI is doable!

Now, for the last question: "I have an app built with MVVM. Should I discard it and start over?"

Please NO! That's not the intention of this article. Let's clarify that!

Don't Throw Away your MVVM app (yet)

Let's go to the point: If you already have an app built with MVVM, keep it as it is for now. Again, throwing away code just for the sake of it is not a good practice, especially if that code is paying the bills.

However, if you have fought for so long with MVVM and want to transition to a better way to implement things, slowly replace your ViewModels until you eventually get rid of all of them in your project.

Now, if you are just starting to create an app in SwiftUI, I strongly recommend you to use MV over MVVM. You will save a lot of headaches :)

Wrap up

As I said at the very beginning, all approaches have tradeoffs. I brought MV to give you a different perspective on making apps in SwiftUI. In this article, we answered three questions transitioning from MVVM to MV:

  • "Hey, Pitt, But what about adding logic into views? That's a bad practice!" - A SwiftUI View is actually a ViewModel! βœ…

  • "How do we approach unit testing without View Models?" - Use Previews to unit-test your views, and create tests that ensure your app behavior works correctly! βœ…

  • "I have an app built with MVVM. Should I discard it and start over?" - No, transition to MV slowly. If you are just starting an app, then use MV directly! βœ…

But there's one more question: "Hey, Pitt, your app is so small, and it perfectly fits MV. What about large apps?".

To answer that, you can look at the Ice Cubes app, a Mastodon Client made in Swift... and MV pattern πŸ˜‰! βœ…

And the final question is: "Is MVVM Necessary for Developing Apps with SwiftUI?" - Not at all. Use MV instead! πŸ˜„

But ultimately, the decision is yours. Tell me, What do you think about MV? Do you think we should stop using MVVM in SwiftUI? Feel free to leave your comments down below.

That's it for me! Remember, my name is Pitt, and this is swiftandtips.com. Thanks for reading, and have a great day! πŸ‘‹πŸ»

Thanks to Mohammad Azam, who helped me find the WWDC23 video explaining how previews can test your views: https://twitter.com/azamsharp/status/1692281931113636232/photo/1

And thanks to Jimmy, who helped me reviewing grammar! 🫢🏼

References

Articles

Videos

Apps

Β