SwiftUI loading states with a twist; Solving a mutation puzzle

Tjeerd in 't Veen

Tjeerd in 't Veen

— 12 min read

SwiftUI simplifies numerous UI-related tasks. However, even something as seemingly simple as a LoadingState enum can quickly become complex when mutation is involved.

Online resources often overlook techniques for mutating data originating from a LoadingState enum. This article aims to change that.

We’ll embark on a journey through various methods of loading and mutating data, starting with a basic view and progressing to the extraction of loading states into @Observable types.

Let’s explore the challenges and solutions to ensure our code maintains tidy, even in seemingly “simple” tasks like data loading.

Socks2bu: Your Go-To App for Sock Organization

Ah, the dreaded laundry day struggle of dumping out the entire basket and embarking on a quest to reunite socks. Some folks even turn to Stack Overflow in desperation to crack this elusive code.

But behold the marvels of modern technology! With the magic of mobile phones, we can simply snap a picture and let the app do the sock matching for us. Imagine the time and frustration we can save!

Introducing Socks2bu, the app that revolutionizes sock management! With Socks2bu, you can create a personalized database of your favorite socks and keep track of their status with ease. And who knows, with a touch of creativity, we could even incorporate cutting-edge live matching technology using the Apple Vision Pro.

While we won’t be diving into the realm of AI-powered sock matching in this article, fear not! We’ll embark on an exciting journey of building models and views to load and display socks, setting the stage for future sock-matching greatness.

So, get ready to roll up your sleeves (and socks) and conquer any obstacles that come our way as we navigate through this thrilling journey.

Let’s dive in and revolutionize the way we manage socks with Socks2bu! 🧦

The model

Before dealing with mutation, let’s begin with the straightforward scenario: loading sock data and rendering it directly into a view.

To start, we’ll define a model to be used in a SwiftUI view.

Introducing the Sock model: representing a single sock retrieved from a backend server. Each Sock object contains an image URL used for loading and displaying the sock.

Out of habit, let’s ensure that Sock conforms to Hashable in advance. This will enable us to utilize hashing algorithms for comparing socks or to enhance support for SwiftUI.

/// The Sock model
struct Sock: Hashable {
    let id: UUID
    let image: URL
}

Instead of using a struct, we could model this as a class using @Observable to more easily support bindings and mutation. However, I stubbornly prefer defaulting to structs for simple data models.

Next, we’ll ensure that we can load a Sock using a SockAPI. It’s important to note that we’ll be using placeholder implementations to allow us to focus on integrating all the components seamlessly.

For the loadSock(id:) function, we’ll return a hardcoded Sock for now.

final class SockAPI {

    /// You'll never guess what loadSock does
    func loadSock(id: UUID) async throws -> Sock {
        // We mimick a loading delay
        // This is to make-believe we're calling a real API
        try! await Task.sleep(until: .now + .seconds(1), clock: .
        continuous)

        // We return a hard-coded Sock to maintain development speed.
        return Sock(id: id, image: URL(string: "https://socks2bu.s3.amazonaws.com/DF2A36FB-E902-47CD-AB85-B9EDAFFBFF5D.jpg")!)

    }

}

These types are used by SockView, which we’ll cover shortly.

Introducing the LoadingState enum

Next, we’ll define the LoadingState enum. We’ll opt for an enum because it allows us to enforce mutually exclusive states at compile-time.

For instance, we may use the LoadingState enum to represent the three possible states a view can be in:

  • failure for errors
  • loading for activity, e.g. a spinning ProgressView
  • loaded containing the actual value.

We can model these states as follows:

enum LoadingState {
    case failure(Error)
    case loading
    case loaded(Sock)
}

With this setup, the loaded value is only accessible when the state is set to loaded.

Next, let’s explore how all these components come together in a SockView.

Implementing SockView

Loading and displaying data are common topics with plenty of online examples, so we’ll keep this section brief.

In this section, we’ll implement SockView, leveraging SockAPI and LoadingState to load and display a Sock.

To manage the loading state, we can utilize State for the loadingState property. We’ll default it to .loading, as State requires us to provide a default value.

The requirement for a default state is why you might encounter a fourth case in some implementations, such as idle or pending, to indicate when no specific state is set.

Alternatively, we could use an optional LoadingState, but this approach can make the SwiftUI code somewhat cumbersome since optional bindings need to be manually unwrapped.

Withing the body of the view, we switch on the loading enum. To trigger the loading process, we invoke the loadData() method from the .loading case using task(priority:_:), which in turn uses the SockAPI.

Upon successful loading of the sock model, we can access its data. In this scenario, we pass its image URL to AsyncImage to load and render the sock image.

Notice that we resize the image so that it fits the screen. For that, we’ll use a closure passed to the content parameter of AsyncImage.

struct SockView: View {

    let id: UUID // The id of the sock to load
    private let loader = SockAPI()
    // We default the loading state to .loading
    // This is @State, since this view owns the loadingState
    @State private var loadingState: LoadingState = .loading

    var body: some View {
        switch loadingState {
        case .failure(let error):
            Text("Error: " + error.localizedDescription)
        case .loading:
            VStack {
                Text("Loading")
                ProgressView()
            }.task {
                // We trigger the loadData call in the loading state
                await loadData()
            }
        case .loaded(let sock):
           // If the sock is loaded, we display its image
           AsyncImage(
                url: sock.image,
                content: { phase in
                    // We make sure the image fits the frame.
                    phase.image?.resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                })
            .padding(20)
        }
    }

    // Triggered by the loading state
    private func loadData() async {
        do {
            let model = try await loader.loadSock(id: id)

            // We update the loading state with the model
            self.loadingState = .loaded(model)
        } catch {
            // On error, we update the loading state with it.
            // Swift trick: We can implicitly refer to the error here.
            self.loadingState = .failure(error)
        }
    }
}

Our UI looks state-of-the-art already. Just look at that beautiful sock.

Before rushing to submit our app to the App Store, let’s pause and reflect on our current state.

Currently, we’re able to successfully load a Sock, but we’re restricted to merely displaying it without any option to mutate it. Essentially, the Sock is read-only, representing the simplest scenario when it comes to loading data. Which is probably why other tutorials stop at this point.

However, now comes the challenging part: introducing mutation and handling the complexities it brings. Let’s dive in and make our app more dynamic.

Adding in mutation

Next we’ll make it so we can modify, or mutate, a Sock instance.

For instance, let’s consider a scenario where we obtain a Sock instance and wish to adjust its level of sogginess within a range from 0 to 100.

On a scale for sogginess, 0 means bone dry and 100 equates to feeling like you’ve just peeled them off after a day toiling in the fields under the scorching sun.

Let’s add a sogginess property to the Sock. We can make it a var so we can modify it.

struct Sock: Hashable {
    let id: UUID
    let image: URL

    // Newly added
    var sogginess: Int = 0
}

Now, let’s imagine we want to view and modify the sock within a new SockDetailView.

SockDetailView accepts a sock via a Binding. We use a Binding because SockDetailView doesn’t store or own the sock; it simply wants to modify its sogginess.

Here comes the tricky part: when we examine our SockView, it seems we can’t directly pass the loaded sock to SockDetailView.

To keep this article shorter, we won’t need to show the internals of SockDetailView.

struct SockView: View {

    // ... snip

    var body: some View {
        switch loadingState {
        // ... snip
        case .loaded(let sock):
            // We replace AsyncImage with SockDetailView.

            // ❌ This won't work, we can't pass sock as a binding.
            SockDetailView(sock: $sock)

            // ❌ Passing sock regularly (without a $) also won't work,
            // because SockDetailView requires a binding.
            SockDetailView(sock: sock)
        }
    }

    // ... snip
}

Inside SockView, we encounter a challenge where we can’t “just pass” a Sock to SockDetailView, either regularly or as a Binding using the $sock notation. This is because even though the sock is indirectly stored in a State property via an enum, it isn’t directly a State property from which we can create bindings.

In this scenario, we don’t get bindings for free anymore, as we’re dealing with an associated value inside an enum. If sock were a property on LoadingState we wouldn’t encounter this issue.

This is a prime example of a very common Swift scenario suddenly becoming tricky in SwiftUI.

This is a missing feature of SwiftUI in my opinion. Unlike properties, we can’t pass associated values as bindings.

Alternatively, we could rely on a third-party framework to solve this, such as CasePaths. But, I’m somewhat hesitant to depend on a third-party framework for a relatively small issue. Ultimately, I hope something like this gets integrated in SwiftUI.

Creating a property on an enum

Let’s explore an alternative approach.

One option is to make sock a property of LoadingState and pass that as a binding. This is because bindings can be created from properties.

This may sound good on paper, but we’ll quickly discover that now we would have to juggle both an optional sock property, as well as the associated value.

Before we come up with a different solution, let’s discover why this approach isn’t optimal. First, we’ll add the sock as a property to LoadingState.

enum LoadingState {
    case failure(Error)
    case loading
    case loaded(Sock)

    var sock: Sock? {
        get {
            switch self {
            case .loaded(let sock):
                return sock
            default:
                return nil
            }
        } set (updatedSock) {
            if let updatedSock {
                self = .loaded(updatedSock)
            }
        }
    }
}

This code won’t win any prizes, but it’s functional.

Next, let’s look at the updated SockView implementation that uses the sock property.

Although we can both retrieve and set a sock property, its usage doesn’t significantly improve our code. Notice how, in the view body, we now disregard the associated value and instead use the property.

However, since sock is optional on LoadingState, we must first unwrap it, introducing another code path to manage.

struct SockView: View {

    // ... snip

    var body: some View {
        switch loadingState {
        // ... snip
        case .loading:
        // ... snip
        case .loaded/* We ignore this now: (let sock) */:

            // The sock property is optional.
            // We first need to unwrap sock to an unwrapped Binding
            if let binding = Binding($loadingState.sock) {
                SockDetailView(sock: binding)
            } else {
                // But what to put here if sock is nil?
                // Shall we leave this empty?
                // Add an assertion?
                // Or a fatalError? With the classic message:
                // "This should never happen"?
            }
        }
    }

}

As you can see, making sock a property solves the problem of having to create a custom Binding. But, we still have to create a new Binding to unwrap the optional sock.

On top of that, we have another code-path that we have to deal with once we receive a sock, because sock is optional. Which should be avoidable, since we know for sure sock is present once LoadingState is set to .loaded. Furthermore, we ignore the associated value in the enum’s .loaded case.

We find ourselves now managing both an optional and a LoadingState enum side by side. Additionally, we had to make a property on the enum, which adds more boilerplate code. We do this not because we want to, but because we don’t get it for free from SwiftUI. Which, I would argue, is not a great motivation or reason to do something.

Alternatively, we could extract Sock out of the enum and make it its own State. But again, we’d have the same problem where we would have to check both the LoadingState enum, as well as an optional sock property on SockView.

One way to avoid having to handle this optional is to provide a default Sock value. Let’s see how that would look.

Using a default value

We can maintain a separate State for Sock. However, to bypass the necessity of handling a code-path for optionals, it does require having some form of default value for when Sock isn’t loaded.

First we have to offer some sort of placeholder value with bogus information. This allows SockView to always have a filled Sock, as opposed to an optional. We can offer a placeholder on Sock for this with random information.

extension Sock {
    static let placeholder = Sock(id: UUID(), image: URL(string: "www.mobilesystemdesign.com")!)
}

I would consider this solution a workaround, not a proper implementation. Because we would use a “fake” model instance.

Next, inside SockView, we add a State Sock next to the LoadingState.

To keep both the separate Sock and LoadingState in sync, we set the local Sock state to the loaded Sock using onAppear(perform:).

struct SockView: View {

    @State private var loadingState: LoadingState = .loading
    // We add a new Sock State to bind to. This uses the placeholder.
    @State private var sock: Sock = Sock.placeholder

    // .. snip

    var body: some View {
        switch loadingState {
        case .failure(let error):
            // ... snip
        case .loading:
            // ... snip
        case .loaded(let loadedSock):
            // We can now bind to the local State sock
            SockDetailView(sock: $sock)
                .onAppear {
                    // We ensure that sock is set to the loadedSock
                    self.sock = loadedSock
                }
        }
    }

    // ... snip
}

This works well and avoids having to manually make a binding thanks to a new State.

However, the drawback is that we introduce a placeholder Sock in production code. I argue that this should be confined to testing code. This placeholder Sock serves only to address a limitation.

Instead, let’s opt for the route where we create a manual binding. This allows us to continue using the associated value, thereby keeping the advantages of using enums with associated values.

Making a Binding manually

Let’s solve our conundrum using a manual Binding.

We’ll go back to having only a LoadingState with a Sock inside of it. So we remove the State Sock we just defined earlier.

Making a Binding manually might seem intimidating, but all we need to supply is a get and set method for it to work.

In our case, in the get method, we return the sock that has just been loaded. In the set method, we update the loadingState with the mutated sock, which we’ll call updatedSock.

struct SockView: View {

    @State private var loadingState: LoadingState = .loading

    // We don't use a local Sock state anymore.

    // ... snip

    var body: some View {
        switch loadingState {
        // ... snip
        case .loaded(let sock):
            SockDetailView(sock: Binding(get: {
                // The sock given to us from the `.loaded` case
                sock
            }, set: { updatedSock in
                // We update the loadingState to reflect the new value.
                loadingState = .loaded(updatedSock)
            }))
        }
    }

    // ... snip
}

With just a few lines, we can safely mutate the sock’s sogginess, and the loadingState inside SockView keeps the data in sync.

This solves the mutation issue for the view and addresses our original problem!

However, we’re not quite finished yet. The updated Sock is not reflected in the data model.

In other words, if we were to navigate away and then return, the sogginess would revert back to 0. Let’s address that next.

Ensuring data outlives the view

We can get creative, and update the model when the binding detects a mutation.

For instance, let’s assume that sockAPI offers an update(sock:) method, which will sync the sock with local stores and the backend.

We’ll call this method from the setter in the custom Binding. Since it’s going to be an async operation (it syncs with backend), we wrap it in a Task. This is because bindings themselves don’t support async behavior.

struct SockView: View {

    // ... snip

    var body: some View {
        switch loadingState {
        // ... snip
        case .loaded(let sock):
            SockDetailView(sock: Binding(get: {
                sock
            }, set: { updatedSock in
                loadingState = .loaded(updatedSock)
                // NEW!
                 Task {
                    try await sockAPI.update(sock: sock)
                }
            }))
        }
    }

    // ... snip
}

Some might argue that updating code from a custom binding isn’t very idiomatic in SwiftUI. However, at least we’ve kept our solution lightweight.

It’s important to note that the sockAPI.update(sock:) method is used as a fire-and-forget call, which might fail in the background. Therefore, with this approach, we need to handle potential failures. Toasts can be a good way to handle errors that occur in the background. Alternatively, we can set the loadingState to .failure inside the Task.

Another approach is to move some of the LoadingState logic to SockAPI. This way, SockAPI can update itself to a .failure state if needed. We’ll explore this shortly.

But first, let’s understand why the idiomatic SwiftUI approach using onChange(of:initial:_:) results in more boilerplate for our scenario.

Using onChange(of:initial:_:)

Currently, we call sockAPI.update(sock:) in the set method of a Binding.

A more idiomatic approach to keep a model in sync is to implement onChange(of:initial:_:) in the view instead. However, by doing so, we will bump into hurdles again.

For instance, it requires the required state to conform to Equatable. That means we must make LoadingState conform to Equatable, which is trickier since it contains associated values where not everything is Equatable, such as Error inside the .failure case. As a result, we can’t get the equality implementation generated — or synthesized — for free at compile-time.

In other words: We now need to offer a manual implementation for Equatable.

Second, we can’t compare the two errors inside failure without trying to downcast the errors or match on their error codes. This is because — in this scenario — the failure contains any Error. We don’t know the exact type until runtime.

To keep it simple and free of boilerplate, let’s move forward while we make an assumption; Let’s assume that both failure states are equal despite the error.

Not properly matching on errors might introduce subtle, yet “fun”, bugs in the future.

enum LoadingState: Equatable {

    static func == (lhs: LoadingState, rhs: LoadingState) -> Bool {
        switch (lhs, rhs) {
        // Lazy implementation: Ideally we completely check the error types.
        case (.failure, .failure): true
        case (.loading, .loading): true
        case let (.loaded(lhsSock), .loaded(rhsSock)): lhsSock == rhsSock
        default: false
        }
    }

    case failure(Error)
    case loading
    case loaded(Sock)
}

Next, we update SockView. We move the Task with the update call into onChange(of:initial:_:). But, because we refer to loadingState — again, because we can’t refer to an associated value as a property — we need to unwrap sock inside the onChange(of:initial:_:) closure.

struct SockView: View {

    // .. snip

    var body: some View {
        switch loadingState {
        // .. snip
        case .loaded(let sock):
            SockDetailView(sock: Binding(get: {
                sock
            }, set: { updatedSock in
                loadingState = .loaded(updatedSock)
                // The Task moves out of here.
            }))
            .onChange(of: loadingState) { oldValue, newValue in
                //  We need to unwrap the enum.
                switch newValue {
                case .loaded(let sock):
                    // Task now moves here
                    Task {
                        try await sockAPI.update(sock: sock)
                    }
                default:
                    break
                }
            }
        }

    }
}

Everything is functioning as expected, and we’ve successfully synchronized the sock data.

However, we’ve really pushed the boundaries here. We’ve had to put in considerable effort and add a significant amount of boilerplate code. All of this just to obtain a binding from a LoadingState enum’s value!

One alternative worth considering is moving LoadingState out of the view. But does this truly resolve the custom-binding problem? Let’s investigate further.

Spoiler alert: The custom-binding problem remains.

Moving LoadingState out of the view

To simplify SockView, we’ll move the loading logic to SockAPI. As a result, SockAPI will get a loadingState property. It can update itself, including errors when update(sock:) fails.

Then SockView can listen for changes on the new loadingState property of SockAPI.

To achieve this, we need to make SockAPI @Observable (available in iOS 17+).

Then, we’ll update SockAPI, so that instead of returning a Sock when calling loadSock(id:), it will set its internal LoadingState to .loaded(sock). And on failure, it could update this state to the .failure case, thus unburdening SockView.

@Observable
final class SockAPI {
    var loadingState: LoadingState = .loading

    // Note how loadSock deesn't throw anymore.
    // loadingState will be set to .failure on error.
    func loadSock(id: UUID) async {
        loadingState = .loading

        // We fake a network call
        try! await Task.sleep(until: .now + .seconds(1), clock: .
        continuous)

        var sock = Sock(id: id, image: URL(string: "https://socks2bu.s3.amazonaws.com/DF2A36FB-E902-47CD-AB85-B9EDAFFBFF5D.jpg")!)
        // Sock is now stored inside loadingState, instead of returned
        loadingState = .loaded(sock)
    }

    func update(sock: Sock) {
        // We update loadingState directly (locally)
        // after mutation
        loadingState = .loaded(sock)
        Task {
            // Meanwhile it fires off a backend call (not depicted)
            // This could potentially fail.
            // If so, we would set loadingState to .failure(error)
        }
    }

}

This is a contrived example for the purpose of keeping this article relatively short. Because, in reality, SockAPI could probably load a million types of socks. Ideally, we would probably introduce yet another new type just to support a single sock.

Looking at SockView below, we see that taking LoadingState out of our view greatly simplifies SockView!

Despite our efforts, however, we can’t avoid having to make a custom binding to connect SockDetailView to the LoadingState enum.

struct SockView: View {

    let id: UUID

    // Using @Bindable means we can react to changes from SockAPI
    @Bindable var sockAPI: SockAPI

    // The body is greatly simplified
    var body: some View {
        switch sockAPI.loadingState {
        case .failure(let error):
            Text("Error \(error.localizedDescription)")
        case .loading:
            ProgressView()
                .task {
                    await sockAPI.loadSock(id: id)
                }
        case .loaded(let sock):
            // We still need to offer a Binding manually.
            SockDetailView(sock: Binding(get: {
                sock
            }, set: { updatedSock in
                // The Task moved to SockAPI.
                // We can remove it here.
                sockAPI.update(sock: sock)
            }))
        }
    }

}

Notice that, to listen to changes from SockAPI, we make the sockAPI property a @Bindable.

One major advantage of this approach, is that a lot of the glue-work is now taken out of the UI layer, keeping SockView smaller.

Additionally, separating the Sock lifecycle from the UI is highly beneficial because it allows the view to respond to state changes without being responsible for managing the state itself.

This makes the feature more portable. In other words: The sock-loading logic is easier to use across frameworks and platforms. Moreover, the loading states are now unit-testable, as opposed to UI-testable.

Read more about why this is important on ‘What if your feature was a Command Line Tool?’.

Conclusion

It appears we can’t really escape the fact that we have to bridge an associated value to a binding. Something as straight-forward and simple in UIKit, such as using a LoadingState enum, becomes an exercise of patching some holes in SwiftUI.

These are just some approaches to support a LoadingState enum with mutation. An easier approach is to depend on a property directly instead to get free binding logic, which you can see in the millions of SwiftUI examples. But then you have to figure out a loading state differently.

This isn’t even the full picture. There is more to it; such as how to handle both loading and passing a Sock model to the same view, but then the code gets more complicated again. If you want to see how to handle that, I recommend checkout out the Mobile System Design book.

How would you tackle this deceptively simple problem?

Want to learn more?

From the author of Swift in Depth

Buy the Mobile System Design Book.

Learn about:

  • Passing system design interviews
  • Large app architectures
  • Delivering reusable components
  • How to avoid overengineering
  • Dependency injection without fancy frameworks
  • Saving time by delivering features faster
  • And much more!

Suited for mobile engineers of all mobile platforms.

Book cover of Swift in Depth


Written by

Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter 1.0 and iOS Tech Lead at ING Bank.