View State Management: Enum vs Properties in SwiftUI

Nicolle Policiano
Policiano
Published in
5 min readMar 28, 2024

In iOS development, efficient application state management is crucial for ensuring dynamic user interfaces and engaging user experiences. The way the view state is managed directly impacts users’ perception of the app’s responsiveness and intuitiveness. In this context, this article explores two different strategies for managing view state: the first one uses different and isolated properties that, together, represent a state of the view, and the second one uses a single enum where each case represents a state of the view.

Managing the view state with multiple properties

A common approach in SwiftUI involves using multiple properties (usually @State or @Binding) to represent different view states. Consider the example below illustrating this strategy in the context of a book list view.

struct BookList: View {
// 1.
@State var isLoading = true
@State var hasError = false
@State var books: [Book] = []

var body: some View {
Group {
// 3.
if isLoading {
// Show loading indicator
} else if hasError {
// Show error view
} else {
if books.isEmpty {
// Show empty list view
} else {
// Show book list
}
}
}
.task {
// 2.
isLoading = true
do {
books = try await getBooks()
hasError = false
} catch {
hasError = true
}
isLoading = false
}
}

func getBooks() async throws -> [Book] {
// fetch books asynchronously ...
}
}

This example can be explained in three parts:

  1. The main thing that can be noticed in this approach is that there are three different properties that have their own responsibility. The isLoading says whether the view should show the loading indicator or not. The hasError is similar, but it drives to the error view state. Finally, the books is where we store the Book list once it is fetched, kind of representing the success case.
  2. Right below, at the task modifier, those properties are changed. The steps are straightforward. The View starts loading and fetching the book list. If the list is successfully fetched, the books are stored in the books property. Otherwise, the view will be tagged with an error.
  3. Eventually, the view will be rendered based on the current values of its properties.

This approach is straightforward but requires careful management to ensure all possible conditions and potential states are accounted for. For example, this design allows inconsistent states such as:

hasError = true
isLoading = true
books = [Book(...), Book(...), Book(...)]

Which state is the view in? Is it loading something? Should it show an error, or should it show the book list? It is not conclusive just by looking at it. This state is not valid.

Therefore, this design allows for impossible states, as in the example mentioned above. Developers can easily make mistakes and forget to reset the states properly.

Additionally, the order of the if statements matters when dealing with this strategy.

State management with Enums

An alternative solution involves encapsulating all possible view states within an enum, which is then managed by a single @State property.

enum ViewState {
case content([Book])
case loading
case error
}

This enum covers the same three possible states presented in the previous approach, and the book list is now integrated as an associated value of the .content case.

This approach not only simplifies state management but also makes the code more readable and maintainable.

See the refactored example below:

struct BookList: View {
@State var state = ViewState.loading // 1.

var body: some View {
Group {
// 3.
switch state {
case .content(let books) where books.isEmpty:
// Show empty list view
case .content(let books):
// Show book list
case .loading:
// Show loading indicator
case .error:
// Show error view
}
}
.task {
// 2.
state = .loading
do {
let books = try await getBooks()
state = .content(books)
} catch {
state = .error
}
}
}

// ...
}

Going deeper into the example above, it’s seen the same three steps:

  1. There is now a single property state that rules them all.
  2. The state is assigned accordingly. There is no need to reset or toggle properties anymore.
  3. The view rendering logic is now placed in an easy-to-read switch-case statement.

Benefits

This strategy offers several benefits:

  • Improved Maintainability and Scalability: Centralizing state management facilitates easier updates and expansions.
  • Comprehensive Coverage: Leveraging Swift’s exhaustive case analysis ensures all possible states are considered, improving code reliability.
  • Mutually exclusive states: Within Swift’s enum, the compiler guarantees that there will be no inconsistent states, so there is no need to reset the properties or manage complex if-else statements.

Considerations on this strategy

Consider a scenario requiring dynamic navigation titles based on ViewState.

.navigationTitle(state == .loading ? "Loading..." : "Book List")

Naturally, the following error may appear:

Xcode error when trying to compare the state property

Implementing this approach requires ViewState to conform to Equatable, which means more lines of code because conforming to Equatable is not always trivial, depending on the complexity of the enum's associated values.

It can be easily solved by introducing a computed property to the specific state:

enum ViewState {
case content([Book])
case loading
case error

var isLoading: Bool {
if case .loading = self { true } else { false }
}
}

Conclusion

In conclusion, using enums to manage app states in SwiftUI makes coding simpler, safer and cleaner. This method puts all the possible states in one place, making it more readable and less likely to miss something. It’s especially good for keeping the code organized and safe from mistakes. However, both the enum way and the multiple @State properties method are useful in Swift. Each has its strengths, so the best choice depends on the needs of the app. I hope this post helps you. See ya!

--

--