Stop using MVVM for SwiftUI

Don’t over-engineering! No suggested architecture for SwiftUI, just MVC without the C.

On SwiftUI you get extra (or wrong) work and complexity for no benefits. Don’t fight the system.

Post not yet marked as solved Up vote post of Appeloper Down vote post of Appeloper
84k views
  • Thank you, very helpful. Especially enjoyed the big fat mess as the end 😂

  • 1/N Thanks for writing this post! I have written many articles regarding SwiftUI and MVVM, I have written books and even created courses on SwiftUI and MVVM. But each time I implemented a solution in SwiftUI and MVVM I felt not comfortable. The main reason was that I was always fighting the framework. When you work on a large application using MVVM with SwiftUI and you want to share state then it becomes extremely hard.

  • 2/N The main reason is that you cannot access EnvironmentObject inside the view model. Well, you can pass it using a constructor but then it becomes pain to do it again and again. And most of the time, you end up getting confused that who is managing the state. I ran into this scenario multiple times and I had to take alternate routes to resolve this problem. One thing to note is that React and Flutter (as original poster said) does not use MVVM pattern. React uses context or Redux and Flutter

Replies

Sorry, the examples are not testable? Not clean? Today developers are obsessed with tests and other ugly thinks. I see projects delayed with lots problems because devs today want to be SOLID and testable.

Agile, SOLID and over testing works like a cancer for development teams.

Sorry but if people want to be complicated they should move to Android or other platforms.

  • Thank you!!! I've felt like a lone crazy person for years advocating against unit testing. In my experience they very rarely (or actually never) catch bugs, cost a lot of time to maintain, and also create ugly architecture. The obsession with number of lines of code covered is especially awful.

    Save unit testing for tricky algorithms, leave it out of simple view/model interactions!

  • Out of interest what test strategy would to apply for a general iOS project? Can you still test most things using MV or you don't bother with unit tests and use UI testing / integration?

    I'm coming from a predominantly .NET background to iOS and still learning SwiftUI - I did groan when I saw MVVM mentioned in more 'advanced' courses, I remember WPF days!

Add a Comment

This is software engineering! KISS (Keep It Simple)

This is SOLID (complicated and obsessed people call this clean…. are you kidding?!)

Remember:

  • Avoid sacrificing software design for testability
  • Automated tests didn’t ensure a quality product

Last years I see a correlation between the increased reliance on automated testing and the decline in quality of software.

A real project that failed and I fixed, one of many problematic projects!

Companies must be careful with devs obsessed with SOLID principles and testability.

In 20 years of software engineering I've never seen soo bad & inefficient developers than in last years. In case of iOS there are very bad developers that come from Web or Android. They fight the system with other platforms concepts and testability talk.

  • OMG, I would love to work with you.

  • Count me in

Add a Comment

I've seen a lot of questions about MVVM (in general and about testability) unnecessary limitations & problems in WWDC22 SwiftUI digital lounge.

I think developers should start understand & think SwiftUI, instead another platform's strategy.

Apple patterns has been used very successfully by Cocoa developers for decades. It just works, and you don't have to fight it. Jumping into more complicated patterns is not a good approach for developers. Your self, your team, your company and your users will thank you!

I fail to understand how a post like this is being upvotes while it encourages a paradigm SwiftUI itself isn’t based on. Speaking to MVU and Elm patterns is different than identifying “MVC without the C”..

  • Can you give any arguments for "a paradigm SwiftUI itself isn’t based on", Appleloper had given us multiple slides directly from Apple, showing that the architecture Apple uses and has in mind with SwiftUI is not necessarily MVVM but something else. Elm pattern is effectively model-view pattern but it doesn't specify other things like data flow(property wrappers like @ObservableObject, @Binding etc.), or navigation(wwdc22). So it feels like Elm pattern is only part of broader "Apple pattern".

  • @NiteOwl, this is really not true, apple itself has begun integrating VM in some example projects or documentation, not to mention that all of the example that the poster keeps spamming on all possible Apple forums and channels are extremely trivial. I tend to not trust who speech with so much confidence, but just look at the project he claims he improved, it is definitely a terrible one to begin with, easy to improve it in any way.

  • pt2. As soon as you start having complex UI logic in place (eg: an animated chatbot, or an interactive tutorial) the last thing you want is to mix it up with business logic, network or db calls and what not. Not to mention that in more and more fields of app development (health, finance, banking, etc) testing is an obligatory and regulated component you cannot avoid even if you think it is bad practice (??).

    Each project has it's own requirements and therefore architecture.

Add a Comment
  • I'm sorry but this session is not WWDC 2022 but 2020.

  • Correct, my fault, sorry. It’s 2020 session when SwiftUI bring @StateObject.

  • Is this the same approach as vue + vuex / pinia? local state is in the views and more complex / shared state you add to a store

The model is composed by Data Objects (structs), Service Objects (providers, shared classes) and State Objects (observable objects, “life cycle” / “data projection” classes). We should use the state objects for specific (or related) data and functionality, not for screen / view, as they should be independent from specific UI structure / needs, When needed we can share then in view hierarchy.

Remember (using state objects in views):

  • StateObject - strong reference, single source of truth
  • EnvironmentObject / ObservedObject - weak reference

Also (when async calls needed):

  • Define state objects (or base class) as MainActor to avoid warnings and concurrency problems
  • Define state object tasks as async, e.g “func load() async”, because for some situations you need to do other jobs after data load completed and async is more simple / sequencial than checking the “phase” (.loaded) using onChange(of:)

Big apps can have more models. This is just an example:

This message is from the original poster of this post. If you get this message please contact me on Twitter at @azamsharp. I am writing a post about the same topic and your help will be really appreciated.

Isn't this example of MV fine until you start expanding, for example if you wanted to select a Product in the List and access this state from multiple views, you could put this state on the Product object or would you put the Product you selected on a state object?

  • The first post of topic is “deprecated”, more focus on local and limited state. In other post I explain with examples. Also, before I tried to make a property wrapper + modifiers to handle some life cycle (of remote data) to avoid create “stores“ (ObservableObject) but failed, we can’t share and is too specific / limited. Local things we can use local state. Remote / life cycle / shared we should use ObservableObject. Again there’s no middle layer, that objects is part of your model.

Add a Comment

Again, you don’t need a middle layer, just your model! Everything becomes easy, composable and flexible. And yes! You can do Unit / Integration tests.

UI = f(State) => View = f(Model)

SignIn

// State (shared in view hierarchy), part of your model
class Account: ObservableObject {
    @Published var isLogged: Bool = false

    func signIn(email: String, password: String) async throws { ... }
}

// View
struct SignInView: View {
    @EnvironmentObject private var account: Account

    @State private var email: String
    @State private var password: String

    // Focus, error state

    var body: some View { ... }

    // Call from button
    func signIn() {
        // Validation (can be on model side)
        // Change error state if needed

        Task {
            do {
                try await account.signIn(email: email,
                                         password: password)
            } catch {
                // Change error state
            }
        }
    }
}

SignUp

// Data (Active Record), part of model, can be an ObservableObject if needed
struct Registration: Codable {
    var name: String = ""
    var email: String = ""
    var password: String = ""
    var carBrand: String? = nil
    
    func signUp() async throws { ... }
}

// State (Store), part of model
class CarBrandStore: ObservableObject {
    @Published var brands: [String] = []
    
    // Phase (loading, loaded, empty, error)

    func load() async { ... }
}

// View
struct SignUpView: View {
    @State private var registration = Registration()
    @StateObject private var carBrandStore = CarBrandStore()
    
    // Focus, error state
    
    var body: some View { ... }

    // Call from button
    func signUp() {
        // Validation (can be on model side)
        // Change error state if needed

        Task {
            do {
                try await registration.signUp()
            } catch {
                // Change error state
            }
        }
    }
}

Yes it's fine for small indie apps. Putting logic in a Model is absolutely ridiculous to me having Observed and State Objects provided by Apple and simply ignoring them because you don't like ViewModel term is just crazy in my opinion. Whatever works for you mate, I like my code modular and testable.

  • This is working o big & professional apps without problems. Again, the model are not only structs with properties.

    Example, StoreKit 2 is a model: Product / Transaction are part of the model, both have properties and logic (funcs and static factories). https://developer.apple.com/documentation/storekit/in-app_purchase

  • Sorry but companies should fire obsessed people with testability & SOLID principles. In last years this obsession are the reason of problematic / complex software projects. 90% of tests (that some people talks) are unnecessary. Over-engineering are not good for you, your team , your company, your customer, your user.

    As software engineer you shouldn’t sacrifice the software design and simplicity for testability.

  • @Appeloper I'm late to this, obviously, but I admit I'm fascinated. Reading through this whole thing.... and something is sticking out to me. YOU are the obsessed one. Dogma personified. I feel bad for people who have to work with you.

    Makes me want to go implement MVVM out of spite.

Add a Comment

ok so If I am getting correctly then Account will have code for validation, network call, caching, routing, etc...

// State (shared in view hierarchy), part of your model
class Account: ObservableObject {
    @Published var isLogged: Bool = false

    func signIn(email: String, password: String) async throws {
     // VALIDATION CODE ****1
     // NETWORK CALL (we will have shared network object) ****2
     // PERSISTENT (we will have shared persistent object) ****3
     // ROUTE TO NEXT SCREEN (we will have global navigator or screen router) ****4
}
}

What we are achieving by giving so much responsibility to Account model.

Instead can't we just go with simple with SignInViewModel, also Validation, Persistency and Routing will not be tight together in Account.

See what I am thinking, and let's justify which one is better.

class SignInViewModel {
    func signIn(email: String, password: String) async throws {
     // NETWORK CALL (we will have shared network object) ****2
}
}

// View
struct SignInView: View {
    @EnvironmentObject private var account: Account

    @State private var email: String
    @State private var password: String

    var body: some View { ... }

    // Call from button
    func signIn() {

        // WE CAN CALL VALIDATION UTILITY FROM HERE ****1

        Task {
            do {
                try await signInViewModel.signIn(email: email, password: password)

              // WE CAN CALL PERSISTENT UTILITY FROM HERE ****3

             // THEN ROUTER CAN ROUTE TO NEXT VIEW  ****4

            } catch {
                // Change error state
            }
        }
    }
}

  • There’s many ways we can do that, but be careful with “single responsibilities” and “separation of concerns”. All these is hurting projects and teams. Don’t over-engineering just because you read “Uncle Bob” SOLID book and the book tell you need to over complicate.

    From my 20 years of experience, SOLID principles are a cancer. Every SOLID based projects I know failed (delayed, not release, complex, not easy to do, not easy to test, not easy to fix, not easy to change, burnout team members).

  • Totally agreed on over engineering that you are saying... but still I have a concern when you talk about SOLID.... are you talking about only S - single responsibility principle or do you think OLID are also useless?

  • I’m talking about all letters of SOLID. SOLID trys to borrows OOP and software design patterns but just over-complicate.. and sometimes becomes anti-pattern. e.g. The MVC, MVVM, MV are the S, in the model layer the WebService, Account, ProductStore are the S. We are separating / grouping the concerns. If we have social sign (Apple, Facebook, Google) and we make an SocialSignInService protocol and implement func signIn(service: SocialSignInService) async we are using others letters.

Model layer

class MyWebService {
    static let shared = MyWebService()
    
    // URLSession instance / configuration
    // Environments (dev, qa, prod, test / mocks)
    // Requests and token management
    
    var token: String? = nil
    
    func request<T>(/* path, method, query, payload*/) async throws -> T { ... }
    
    func requestAccess(username: String, password: String) async throws { ... }
    func revokeAccess() async throws { ... }
}
// Other names: User, UserStore, CurrentUser, ...
class Account: ObservableObject {
    @Published var isLogged: Bool = false
    @Published var error: Error? = nil
    
    struct Profile {
        let name: String
        let avatar: URL?
    }
    
    @Published var profile: Profile? = nil
    
    enum SignInError: Error {
        case mandatoryEmail
        case invalidEmail
        case mandatoryPassword
        case userNotFound
        case userActivationNeeded
    }
    
    func signIn(email: String, password: String) async {
        // Validation
        if email.isEmpty {
            error = SignInError.mandatoryEmail
            return
        } else if email.isEmail {
            error = SignInError.invalidEmail
            return
        }
        
        if password.isEmpty {
            error = SignInError.mandatoryPassword
            return
        }
        
        // Submit
        do {
            try await MyWebService.shared.requestAccess(username: email,
                                                        password: password)
            isLogged = true
            error = nil
        } catch HTTPError.forbidden {
            error = SignInError.userActivationNeeded
        } catch HTTPError.notFound {
            error = SignInError.userNotFound
        } catch {
            self.error = error
        }
    }
    
    func signOut() async {
        // Ignore any error
        try? await MyWebService.shared.revokeAccess()
        isLogged = false
    }
    
    func loadProfile() async {
        do {
            profile = try await MyWebService.shared.request(...)
            error = nil
        } catch {
            self.error = error
        }
    }
}

View layer

@main
struct MyApp: App {
    @StateObject var account = Account()
    
    var body: some Scene {
        WindowGroup {
            Group {
                if account.isLogged {
                    TabView { ... }
                        .task {
                            async account.loadProfile()
                        }
                } else {
                    SignInView()
                }
            }
            .environmentObject(account)
        }
    }
}
struct SignInView: View {
    @EnvironmentObject var account: Account
    
    @State private var email: String = ""
    @State private var password: String = ""
    
    // Text fields, button, error exception
    var body: some View {
        ScrollView { ... }
    }
    
    func signIn() {
        Task {
            await account.signIn(email: email,
                                 password: password)
        }
    }
}
  • Well, this implementation looks good, but what you call this MV or Active Record or something else?

  • This is MV. Not using Active Record at all for data types but in fact an ObservableObject is (or can be) an Active Record at it nature. This is another approach where you call WebService directly from state (ObservableObject) and don’t use func / factories in data types / model.

  • Yeah it is MV... and following some principles in balance mode not over engineered. You put things in isolation that is good.

How some developers, obsessed by testability & SOLID principles, think “separation of concerns”, making something simple, ..complex, problematic, expensive.

  • I was looking this bi-cycle again, it is not over engineered code, it seems broken code... you know over engineered code also works, but here your bi-cycle in non-functional, it not over engineered... still a good bi-cycle follows SoC, SOLID, TDD... both tyres has a single responsibility, if you work one first tyre there won't be any affect on second tyre, and this is also good in your code, if you touch one area of code other are won't be affected...

  • Both tyres can be tested independently, even in any other cycle also... so TDD is important here... The whole cycle also following O in SOLID, open closed principle... you can add new functionality in this bi-cycle like you can add ****, some kind of led lights in rings, carrier, and all that... same thing we should able to do in our code, we should be able to add new functionality without affecting our existing functionalities...

  • This broken or non functional bi-cycle example will confuse other developers regarding over engineered code Vs applying XP, TDD, SOLID, SoC, etc.. in their code. Even new developers will hesitate to touch all these...

Model layer

class MyWebService {
    static let shared = MyWebService
    
    // URLSession instance / configuration
    // JSONDecoder/Encoder configuration
    // Environments (dev, qa, prod, test / mocks)
    // Requests and token management
    
    var token: String? = nil
    
    func request<T: Codable>(/* path, method, query, payload*/) async throws -> T { ... }
    
    func requestAccess(username: String, password: String) async throws { ... }
    func revokeAccess() async throws { ... }
}
struct Product: Identifiable, Hashable, Codable {
    let id: Int
    let name: String
    let description: String
    let image: URL?
    let price: Double
    let inStock: Bool
}
class ProductStore: ObservableObject {
    @Published var products: [Product] = []
    
    enum Phase {
        case waiting
        case success
        case failure(Error)
    }
    
    @Published var phase: Phase? = nil
    
    func load() async {
        do {
            phase = .waiting
            products = try await MyWebService.shared.request(path: “products”)
            phase = .success
        } catch {
            phase = .failure(error)
        }
    }
}

View layer

struct ProductList: View {
    @StateObject var store = ProductStore()

    var body: some View {
        List { ... }
            .task {
                await store.load()
            }
            .navigationDestination(for: Product.self) { product in
                ProductView(product: product)
                // (--- OPTIONAL ---)
                // Only if you need to make changes to the store and that store its not already shared
                // ProductView(product: product)
                //    .environmentObject(store)
                // (--- OR ---)
                // ProductView(product: product, store: store)
            }
    }
}
struct ProductView: View {
    var product: Product
    // (--- OR ---)
    // If you make changes to the product
    // @State var product: Product    

    // (--- OPTIONAL ---)
    // Only if you need to make changes to the store and that store its not already shared
    // @EnvironmentObject private var store: ProductStore
    // (--- OR ---)
    // @ObservedObject var store: ProductStore
    
    var body: some View {
        ScrollView { ... }
    }
}