Stop using MVVM and abusing Observable classes in SwiftUI

Lazar Otasevic
4 min readNov 17, 2023

--

This is example of how MVVM is typically implemented by abusing Observable classes in SwiftUI and it is showing how it is a really BAD idea.

SwiftUI is not yet another MV hybrid

SwiftUI is based upon ELM architecture, with the exception that ELM is unidirectional and SwiftUI is not. That is exactly what TCA tried to “fix” but at a cost. We better unlearn those MV hybrids in order to be successful in SwiftUI and go with the native flow and embrace fully what Apple gave us.

What makes SwiftUI.View a View?

Nothing! There is nothing inside SwiftUI.View that a view should have, no frame, no color, no nothing. Its just a protocol that ANY value can conform to. Unbelievably, even Int can conform to SwiftUI.View. This perfectly works:

extension Int: View {
var body: some View { Text("I am \(self) and I am not a view") }
}

struct PerfectlyFineView: View {
var body: some View { 1 }
}

struct CounterView: View {
@State var count = 1
var body: some View { counter }
}

We have been misled by Apple. SwiftUI is a declarative framework and SiwftUI.View is more like a “view descriptor” and not a real view. In ELM architecture they call it a “virtual view”.

Where the logic stops insanity begins

Lets step back and use a “normal” SwiftUI.View and pretend its a real view.

struct NiceView: View {
@State private var counter = 0
var body: some View {
Button("Inc: \(counter)") { counter += 1 }
}
}

That isn’t “clean” as Uncle Bob would like it to be. You can not test views they say, even though it isn’t true in SwiftUI. You CAN test SwiftUI.View because its not a view.

But let’s keep pretending SwiftUI.View is a real view and take out the business logic, at least what we think the business logic is:

final class NiceViewModel: ObservableObject {
@Published var counter = 0
func inc() { counter += 1 }
}

struct NiceView: View {
@StateObject private var vm = NiceViewModel()
var body: some View {
Button("Inc: \(vm.counter)", action: vm.inc)
}
}

Great! Now it is “clean” and testable. Now lets try to inject some dependency. Using @Environment like a pro.

final class NiceViewModel: ObservableObject {
@Environment(\.increaser) var increaser // oops this does not work!
@Published var counter = 0
func inc() { counter = increaser(counter) }
}

It does not work inside classes, it works only inside a SwiftUI.View structs. We just broke native dependency injection. No big deal because we are keeping it “clean”.

What about other wrappers from Apple? Wrappers like @AppStorage or @Query? Nope, its all broken! But lets ignore the fact that we broke fundamental properties for now.

Lets take ALL the business logic out

As every SwiftUI.View is just a value, then body is just a computed property that returns some value. A code that returns a value looks like a business logic to me, so lets move it out of the view.

final class NiceViewModel: ObservableObject {
@Published private var counter = 0
private func inc() { counter += 1 }
var body: some View {
Button("Inc: \(counter)", action: inc)
}
}

struct NiceView: View {
@StateObject private var vm = NiceViewModel()
var body: some View { vm.body }
}

Congrats, this also works. We moved entire SwiftUI view code from a struct to a class. Our SwiftUI.View is now an empty shell. We are moving in circle but our code is fully “clean”.

So we almost don’t use the SwiftUI.View here. Why Apple bothered making all of that (supposedly untestable) mess using value types when we can make it testable and “clean” (and a bit broken) using classes? Someone may start thinking that we are doing something wrong here. Ofcourse we are wrong.

Conclusion

It is clear now that MVVM pattern implemented by abusing Observable classes is a major fail in SwiftUI. It clearly breaks usage of native property wrappers. Some say it has to be done or else we could not test it. First we CAN test it. Second, why even bother testing this “clean” sh*t? It failed even before the test, its broken.

We have to find a better way to make SwiftUI “cleaner”. There are ways that I found out if you need them, but more on that some other time. Or clap and write a comment so we can discuss. Thanks.

Check my next story about testable views with a GitHub repo!

--

--