SwiftUI Observation Framework: State Containers

Luis Recuenco
The Swift Cooperative
5 min readJan 5, 2024
Photo by Guillaume Bolduc on Unsplash

Introduction

I first wrote about state containers back in 2017. In its simplest form, a state container is just a wrapper type over some State type.

class StateContainer<State>: ObservableObject {
@Published var state: State
}

Most unidirectional architecture frameworks have a similar base class, so we model all our feature state inside that State type, whether that’s data that should trigger view renders or not.

And that’s one of the main bottlenecks of some Redux-ish architectures that tend to model all the app state in a single place: view bodies recompute even if the state change was unrelated to that view. There are certainly ways to fix that (like TCA’s ViewStore), but as always, that comes with complexity and also with a feeling that we are kind of fighting the framework.

Fortunately, the new Observation framework is here to fix this. Or not… Let’s see.

Unnecessary re-rendering

Let’s have some foundation in place first via the simplest example that I could come up with. An app that generates a random number and shows it on screen. It also has a “noop” button, which does nothing (it just sets a piece of private state).

// CounterView.swift
struct CounterView: View {
@StateObject private var viewMoldel = CounterViewModel()

var body: some View {
VStack {
Text("\(viewMoldel.state.count)")
Button("Random number") { viewMoldel.generateRandomNumber() }
Button("Noop") { viewMoldel.noop() }
}
}
}

// CounterViewModel.swift
struct CounterViewState {
fileprivate(set) var count = 0
fileprivate var privateCount = 0
}

class CounterViewModel: StateContainer<CounterViewState> {
func generateRandomNumber() {
state.count = Int.random(in: 0 ..< 100)
}

func noop() {
state.privateCount = Int.random(in: 0 ..< 100)
}
}

As you can imagine, setting both the count (which is the state read in the view body) or the privateCount (which is not read by the view), will trigger the view body to recompute.

A possible option to fix this issue is to extend the State type so we have a little more control over when the change should trigger a render pass. Something like this:

protocol StateType {
static func shouldViewUpdate(lhs: Self, rhs: Self) -> Bool
}

extension StateType {
static func shouldViewUpdate(lhs: Self, rhs: Self) -> Bool {
true
}
}

class StateContainer<State: StateType>: ObservableObject {
var state: State { // Ideally protected
willSet {
guard State.shouldViewUpdate(lhs: newValue, rhs: state) else {
return
}
objectWillChange.send()
}
}
}

And now, each State type can tell when a change should trigger the view render:

struct CounterViewState: StateType {
fileprivate(set) var count = 0
fileprivate var privateCount = 0

static func shouldViewUpdate(lhs: Self, rhs: Self) -> Bool {
lhs.count != rhs.count
}
}

If you are familiar with React, this is similar to shouldComponentUpdate.

Of course, this is not ideal and it’s quite error-prone. Having to update shouldViewUpdate whenever we add properties read by the view will likely end up getting out-of-sync. There are other better ways to do this, like having an explicit Rendered type.

protocol StateType {
associatedType Rendered: Equatable
var rendered: Rendered { get }
}

extension StateType {
static func shouldViewUpdate(lhs: Self, rhs: Self) -> Bool {
lhs.rendered != rhs.rendered
}
}

In this case, the view will always read our rendered type and will only render as long as the new rendered is different from the previous one. But as always, this imposes constraints on our StateType, increasing the overall complexity and removing some freedom about how to model our state.

Fortunately, the new Observation framework is here to help.

Enter the @Observable macro

iOS 17 changed completely how we observe changes from SwiftUI views.

  • SwiftUI is no longer coupled with the Combine framework when it comes to observing external state updates (we don’t need @Published properties any longer).
  • Performance is improved as the view will only update as long as the related state changes (the one read inside the view body).

That premise is really nice. So let’s refactor our previous code and see what happens:

@Observable
class StateContainer<State> {
var state: State

init(initialState: State) {
self.state = initialState
}
}

// Inside our view...
@State private var viewMoldel = CounterViewModel()

Interestingly, the view body is still recomputed when tapping on the “noop” button 🤔.

If we think about it, “it makes sense”. We are marking the StateContainer as @Observable, which observes all the underlying properties inside it (in this case, only the state). Being state a value type, changing a piece of private state inside it mutates the whole value type, which will trigger the view body recomputation, regardless of the actual mutation and if that affects the view or not.

While this “makes sense”, it is also confusing and it can greatly affect performance and unnecessary re-renders when modeling our state using value types.

The solution would be to mark the underlying State as @Observable. Unfortunately, this cannot be done with value types. At least for now

Leaving value types behind

As long as I like to model my state with value types, both the Observable framework and SwiftData (with its @Model macro) are forcing a lot of our types to be reference types, whether we like that or not…

The solution to our problem is just to change our State type to be a class.

class StateContainer<State> { } // This no longer needs to be Observable

@Observable
final class CounterViewState {
fileprivate(set) var count = 0
fileprivate var privateCount = 0
}

But if we really want to use value types, we have other options…

ObservableState

The Pointfree guys have come up with a clever way to bypass this limitation for value types, by creating their own macro @ObservableState. Hopefully, that macro will get its own library one day so we can use it without the whole TCA library. The change is just as simple as marking our value type with the @ObservableState macro.

@ObservableState
struct State {
var count = 0
fileprivate var privateCount = 0
}

Only you can decide if adding that macro is worth it for the value semantics benefits… 🤷‍♂.

Conclusion

Fortunately, all this won’t be an issue as long as we don’t use these kinds of generic state containers with that single State type. If we just happen to use a simple view model, in most cases, we can just mark our view model as @Observable and be sure that mutating a private property won’t trigger view re-renders.

But the truth is that this problem affects a lot of unidirectional libraries that are migrating to the Observable framework. Of course, there’s a simple “solution”, by forcing the State type to be an Observable reference type.

protocol StateContainer {
associatedtype State: Observable
var state: State { get }
}

Depending on the app, that might be a big constraint (State can’t be an enum for instance…) for negligible performance benefits. As always, it depends.

In any case, hopefully, Swift will support Observable value types in the future, making all this article obsolete.

Looking forward to that.

--

--

Luis Recuenco
The Swift Cooperative

iOS at @openbank_es. Former iOS at @onuniverse, @jobandtalent_es, @plex and @tuenti. Creator of @ishowsapp.