Reducing Boilerplate in UIKit State Binding
tl;dr
Tired of writing isHidden = true
three times every time you bind view state?
This article walks through a small refactor to reduce repetitive UIKit state-binding boilerplate — making your code clearer and easier to maintain.
Introduction
Modeling the state of asynchronous screens using an enum with associated values is a useful and common pattern.
It collapses multiple sources of truth into a single state object, eliminating optionals and reducing complexity:
class SomeScreen {
var data: SomeData?
var isLoading: Bool = false
var errorMessage: String?
}
vs
enum State<T> {
case loading
case success(T)
case error(String)
}
class SomeScreen {
var state = State<SomeData> = .loading
}
This is particularly useful in SwiftUI
where view is a function of state and no bindings are needed:
struct SomeScreen: View {
@State var state = State<SomeData> = .loading
var body: some View {
switch state {
case .loading: ProgressView()
case .success(let data): SuccessView(data: data)
case .error(let error): ErrorView(message: error)
}
}
}
In UIKit codebases, it’s common to see the pattern used as follows:
final class SomeViewController: UIViewController {
lazy var indicator = LoadingView()
lazy var someView = SomeView()
lazy var errorView = ErrorView()
var state = State.loading {
didSet {
switch state {
case .loading:
indicator.isHidden = false
someView.isHidden = true
errorView.isHidden = true
case .success(let data):
indicator.isHidden = true
someView.isHidden = false
errorView.isHidden = true
someView.update(with: data)
case .error:
indicator.isHidden = true
someView.isHidden = true
errorView.isHidden = false
}
}
}
}
However, this is not only verbose but error prone as we may get bindings mixed up:
...
switch state {
case .loading:
indicator.isHidden = false
someView.isHidden = false ❌
...
}
And because each screen following this pattern repeats the same snippet, it quickly becomes boilerplate.
Thankfully, Swift, being the expressive language that it is, allows for some customization that can greatly reduce this boilerplate.
Readable view visibility
By nature, you can name any bool in two opposite ways.
When it comes to visibility you could have a bool that reads:
var isHidden: Bool { ... }
But depending on your preferences you might prefer expressing visibility using the opposite wording:
var isVisible: Bool { ... }
When expressing logic in code, affirmative conditions are usually easier to parse than their negated counterparts:
Although isHidden
isn’t grammatically a negative, it behaves like one semantically. Assigning someView.isHidden = false
forces your brain into a small mental rephrasing:
The view is not hidden… which means it is visible.
An unnecessary flip compared to:
The view is visible.
isVisible = true
maps directly to “this view should be shown”, whereas isHidden = false
adds a small mental detour.
So in the context of view visibility, isVisible
better reflects intent and simplifies state binding.
For example, instead of scattering multiple conditionals across states:
case .loading:
indicator.isHidden = false
case .success:
indicator.isHidden = true
case .error:
indicator.isHidden = true
We can reduce the noise with a single line:
indicator.isVisible = state.isLoading
You can introduce this semantic clarity by extending UIView with a computed property that inverts isHidden
:
extension UIView {
var isVisible: Bool {
get { !isHidden }
set { isHidden = !newValue }
}
}
State helpers
Now that we have a clearer boolean, we’ll need to add some helpers to the State
model so we can bind the isVisible
property easily:
extension State {
var isLoading: Bool { self == .loading }
var data: T? {
switch self {
case .success(let data): return data
default: return nil
}
}
var isSuccess: Bool { data != nil }
var isError: Bool {
switch self {
case .error: return true
default: return false
}
}
}
Usage
final class SomeViewController {
lazy var indicator = LoadingView()
lazy var someView = SomeView()
lazy var errorView = ErrorView()
var state = State<Model>.loading {
didSet { bindUI() }
}
func bindUI() {
indicator.isVisible = state.isLoading
someView.isVisible = state.isSuccess
errorView.isVisible = state.isError
state.data.map(someView.update)
}
}
Bonus
When dealing with completion-based async code, it’s common to use Swift’s Result
type to model success or failure outcomes.
Then the controller / viewModel / whatever, maps the result into a state value:
class SomeViewController {
...
func load() {
loader.load { result in
switch result {
case .success(let data) : self.state = .success(data)
case .failure(let error): self.state = .error(error)
}
}
}
}
And again, since this block tends to repeat across screens, it’s boilerplate.
It can be refactored out into a custom initializer:
extension State {
init(result: Result<T, Error>) {
switch result {
case .success(let data): self = .success(data)
case .failure(let error): self = .error(error.localizedDescription)
}
}
}
Then in the controller (weak self omitted for brevity):
class SomeViewController {
...
func load() {
loader.load { self.state = .init(result: $0) }
}
}
Conclusion
All code is buggy. It stands to reason, therefore, that the more code you have to write the buggier your apps will be. — Rich Harris
These small changes not only reduce repetition and bugs, they also make your ViewControllers more declarative and less error prone.
In a time where UIKit is still very much alive in many codebases, small improvements like this can make day-to-day development a bit cleaner and more maintainable.
Feedback
We all need people who will give us feedback. That’s how we improve. — Bill Gates
Any feedback — technical, editorial, or otherwise — is more than welcome.
If you have thoughts, suggestions, or even gentle corrections, feel free to send me an email: