UI State Management in SwiftUI

UI State Management in SwiftUI

Exploring Simple Strategies for UI State Management in SwiftUI

Introduction

In SwiftUI, when we start creating a new View, one of the first things we thought is the UI states, like what it should look like when that one View of the app is still fetching data, encounters an error, when data is empty, and when there is data.

We usually start simple, something like this:

@State private isLoading: Bool = false

var body: some View {
    ZStack {
       if isLoading {
          ProgressView()
       } else {
          Text("Hello World!")
       }
    }
}

Then at some point, it gets messy though somehow manageable, like this:

@State private isLoading: Bool = false
@State private hasError: Bool = false
@State private hasData: Bool = false

var body: some View {
    ZStack {
       if isLoading {
          ProgressView()
       } else if hasError {
          Text("Encountered Error")
       } else if hasData {
          Text("Data is loaded here")
       } else if !hasData {
          Text("There is no data....")
       }
    }
}

Notice that every time we add a state, we create a new @State variable, surely things will get crowded.

Enum Driven

Years ago, I came across an interesting approach called 'enum-driven', which is a good strategy for handling distinct states or values. This technique works well when these states are exclusive and don't overlap with one another.

So in this context, let us assume that states can never be both hasData and hasError.

enum ViewState {
    case loading
    case populated
    case empty
    case error
}

With Enum, we can use switch-case statements.

switch viewModel.state {
case .populated:
    Text("Populated")

case .empty:
    Text("Empty")

case .loading:
    ProgressView()

case .error:
    Text("Error")
}

Manage within its View

In your View, create a @StateObject property with the variable name state.
For the sake of this example we shall make a simple View struct, and using ZStack .

@State private var state: ViewState = .empty

var body: some View {
        ZStack {

            switch state {
            case .populated:
                Text("Populated")

            case .empty:
                Text("Empty")

            case .loading:
                ProgressView()

            case .error:
                Text("Error")
            }
        }

    }

if you want to simulate a loading state you may attach a ViewModifier on the ZStack.

 .onAppear {
            state = .loading

            DispatchQueue.main.asyncAfter(
                deadline: .now() + .seconds(Int(3.0))) {
                    state = .populated
            }
 }

Manage with ViewModels

We have to conform the ViewModel to ObservableObject so the View can observe the changes.

@Published allows us to create observable objects that automatically publish or announce when changes occur.

Apple docs states

Publishing a property with the Published attribute creates a publisher of this type.

When the property changes, publishing occurs in the property’s willSet block, meaning subscribers receive the new value before it’s actually set on the property.

class CustomViewVMViewModel: ObservableObject {
    @Published var state: ViewState = .empty
}
switch viewModel.state {
            case .populated:
                Text("Populated")

            case .empty:
                Text("Empty")

            case .loading:
                ProgressView()

            case .error:
                Text("Error")
}

Manage by its parent View

Create the parent View.

struct CustomContainerMainView: View {
    @State private var state: ViewState = .empty

    var body: some View {
        CustomChildView(state: $state)
    }
}

then the Child View.

struct CustomChildView: View {
    @Binding var state: ViewState

    var body: some View {
        ZStack {

            VStack {
                switch state {
                case .populated:
                    Text("Populated")

                case .empty:
                    Text("Empty")

                case .loading:
                    ProgressView()

                case .error:
                    Text("Error")
                }

                Button {
                    state = .loading
                } label: {
                    Text("Start Loading")
                }

                Button {
                    state = .error
                } label: {
                    Text("Show Error")
                }

                Button {
                    state = .populated
                } label: {
                    Text("Show Populuted")
                }

            }

        }
    }
}

Notice that we use @Binding instead of let , so we are giving the child view the power to change the value of the state that was owned by the Parent View.
Using between @Binding var vs let will depend on you.

To illustrate the loading state, update your parent view by adding a Button to start loading.

VStack {
      CustomChildView(state: $state)

      Button {
          state = .loading
      } label: {
          Text("Parent Button - Start Loading")
      }
      .buttonStyle(.borderedProminent)

}

Final Thoughts

When implementing this we have to keep in mind and ask these questions.

  1. "Who should be responsible for managing the state of this particular View?"

  2. "Are we putting too much responsibility on this View, or ViewModel, or ParentView?"

  3. "Do we have to always follow this pattern?"

  4. "How many States or Cases should this particular view have?"

  5. "Are we making this too complicated or too simple?"

  6. "Does this scale well, flexible, and maintainable?"

The implementation will depend on the use case, scenario, and needs.

Let me know if you have similar implementations, and have other scenarios. From here, I'll be exploring more, experimenting more, and trying different complex scenarios.

Thanks for reading! 📖

You can check the sample project here