Using a struct property inside an Observable object

I'm just taking some time to explore SwiftUI / Observable a little more in depth and specifically looking at dependency injection via the SwiftUI Environment.

I have the following setup:

Code sample

@Observable
class AppState {
    var dataRepository = DataRepository()
}

struct DataRepository {
    private(set) var isLoading = false
    
    mutating func downloadData() async -> String {
        isLoading = true

        try? await Task.sleep(for: .seconds(3))

        isLoading = false
        return "Some data"
    }
}

Then this AppState is injected to the app and views where it's used

@main
struct DependencyInjectionApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .environment(AppState())
    }
}

struct ContentView: View {
    @Environment(AppState.self) private var appState
    
    @State private var name: String = ""
    
    var body: some View {
        Group {
            if appState.dataRepository.isLoading {
                ProgressView()
            } else {
                Text(name)
            }
        }
        .padding()
        .task {
            name = await appState.dataRepository.downloadData()
        }
    }
}

#Preview {
    ContentView()
        .environment(AppState())
}

This code causes...

  • The name to update to "Some data"
  • No progress view to show while loading inside

On inspection this is because...

  • The @State for the name property updates once the download happens and redraws the view
  • The isLoading is inside a struct inside of an Observable class so doesn't redraw the view

If I...

  • Change the isLoading to be on the AppState this causes a redraw (via AppState being an @Observable)
  • Change the DataRepository to be a class with @Observable then this also causes redrawing

But...

I'd have expected the fact DataRepository is a value type would cause the @Observable AppState to see something has changed and cause a redraw to SwiftUI, which doesn't seem to be the case

Am I missing something or is this just a limitation of @Observable?

(While the question looks like a SwiftUI specific question, IIUC it's about how observable and async code work together and the behavior can be reproduced by using non-SwiftUI code.)

   mutating func downloadData() async -> String {
       isLoading = true // step 1

       try? await Task.sleep(for: .seconds(3)) // step 2

       isLoading = false
       return "Some data" // step 3
   }

I think the issue is with the above code. The downloadData() async func receives a mutable self. It first sets its isLoading property, then call an async func, which causes itself suspended. You seemed to think that onChange closure of withObservationTracking should be triggered at step 1. I don't think that's correct. I think it's triggered at step 3, because that's how inout works on concept.

PS: "Using Swift" forum is probably a better place for this question.

3 Likes

Correct! That's what I'd expect from my (limited) knowledge of value types in Swift

PS: "Using Swift" forum is probably a better place for this question.

Apologies I thought I put this in general discussion but accidentally put it in the evolution discussion.

I just moved this in the "Using Swift" forum, thanks!

It's easy to move a topic by merely changing its category (I just did it for this topic). No need to create a copy (I suggest you remove that one).

Thanks @tera !

Consider this example:

class AppState {
    var dataRepository = DataRepository() {
        willSet { print("willSet") }
        didSet { print("didSet") }
    }
}
struct DataRepository {
    var isLoading = false
    mutating func downloadData() {
        for i in 0 ..< 100 {
            isLoading.toggle()
        }
    }
}
print("start")
var appState = AppState()
appState.dataRepository.downloadData()
print("end")

In this example isLoading is getting toggled 100 times although dataRepository's willSet / didSet is only called once. Observable using a similar mechanism and thus experiences the same behaviour.

2 Likes

Thanks for the explanation @tera that explains what I'm seeing

OOC is there anywhere I can read more in to when/where/why willSet/didSet is only called once the function finishes? I would have expected that example code mutating func downloadData() to have changed the state of the struct 100 times so would be good to read in to why it doesn't do that a bit more

It’s always called exactly once when passed as an inout parameter (or mutating self), even if the value doesn’t actually change. That fact has also bitten me in the past and I’ve had to say guard value != newValue in didSet listeners before.

2 Likes

I'd be interested in that as well.

My hand-wavy way of understanding it:

  1. mutatingFunction() is equivalent to nonMutatingFunction(&self).
  2. nonMutatingFunction(&self) is semi-equivalent (exclusivity checks and optimisations aside) to self = nonMutatingFunction(self)
  3. for x = foo(...) if x has a set or willSet/didSet observer pair, they are called once when the foo(...) call is completed and its result is available (e.g. to pass it to newValue of willSet):
// pseudocode:
let newValue = foo(...)
x.willSet(newValue: newValue)
let oldValue = x
x := newValue
x.didSet(oldValue: oldValue)

@tera I think the key to understand this behavior is what Jordan Rose said in this post:

1 Like