Custom bindings in SwiftUI: closures vs subscripts
In SwiftUI, bindings let a view edit state that is owned elsewhere in the hierarchy. When the value is stored as a property on an observable model, the $ syntax gives us a binding to that property without any additional work.
But sometimes the value a view needs to edit doesn't exist as a stored property. It might be derived from other model state, or it might represent a projection into a collection. In these situations, we need to create a custom binding that describes how to read and write that value.
The most obvious way to create a custom binding is to use a Binding initializer that accepts get and set closures. This method works, but it can cause some performance issues when the view that creates the binding reevaluates frequently. In this post, we'll review how a closure-based binding can affect view updates, and see an alternative approach with a subscript-based binding that gives SwiftUI a more stable dependency to track.
# Creating a binding with get and set closures
With a closure-based binding, we create a Binding value by providing a getter and a setter. The getter returns the current value, and the setter writes a new value back to the underlying storage. This gives us a lot of flexibility, because the binding doesn't have to map to a stored property directly.
In the example below, the model stores granted permissions as a set of IDs, while each permission row needs a boolean binding that controls whether that specific permission is granted.
@Observable
final class TeamPermissionsModel {
var grantedPermissionIDs: Set<Permission.ID> = []
}
struct PermissionRow: View {
let permission: Permission
@Binding var isGranted: Bool
var body: some View {
VStack(alignment: .leading) {
Toggle(permission.name, isOn: $isGranted)
Text(permission.description)
.font(.caption)
}
}
}
A straightforward way to bridge the model storage with the row's boolean binding is to create a custom binding inline with Binding(get:set:). The getter checks whether the permission ID is present in the set, and the setter inserts or removes it when the toggle changes.
struct TeamPermissionsView: View {
let permissions: [Permission]
@State private var model = TeamPermissionsModel()
var body: some View {
List {
ForEach(permissions) { permission in
PermissionRow(
permission: permission,
isGranted: Binding(
get: {
model.grantedPermissionIDs.contains(permission.id)
},
set: { isGranted in
if isGranted {
model.grantedPermissionIDs.insert(permission.id)
} else {
model.grantedPermissionIDs.remove(permission.id)
}
}
)
)
}
}
}
}
The downside of this approach is that the binding is recreated every time TeamPermissionsView evaluates its body. Each new binding is initialized with new getter and setter closures, and those closures require heap allocations, so the view does extra work whenever its body runs.
There is another problem that can be more visible in practice. TeamPermissionsView can reevaluate for reasons that have nothing to do with the permission rows, but it still creates new closure-based bindings for every row. SwiftUI can't compare those new closures in a meaningful way, so the row inputs can appear to change even when the granted permissions stay the same.
For example, we can add a teamName property to the model and bind it to a text field in the same view.
@Observable
final class TeamPermissionsModel {
var grantedPermissionIDs: Set<Permission.ID> = []
var teamName = ""
}
struct TeamPermissionsView: View {
let permissions: [Permission]
@State private var model = TeamPermissionsModel()
var body: some View {
List {
TextField("Team Name", text: $model.teamName)
ForEach(permissions) { permission in
PermissionRow(
permission: permission,
// This binding is recreated on every body evaluation,
// which can trigger extra row body updates.
isGranted: Binding(
get: {
model.grantedPermissionIDs.contains(permission.id)
},
set: { isGranted in
if isGranted {
model.grantedPermissionIDs.insert(permission.id)
} else {
model.grantedPermissionIDs.remove(permission.id)
}
}
)
)
}
}
}
}
Changing teamName doesn't change the granted state for any permission, but TeamPermissionsView still recreates the closure-based bindings during body evaluation. As a result, the permission rows can sometimes be evaluated again even though their visible data didn't change. Placing _printChanges() in the row view body can help reveal those extra evaluations in the console.
Even if we moved the Binding(get:set:) creation into a helper on the model, the underlying issue would be the same. The call site might look cleaner, but the helper would still create a new closure-based binding every time body asks for it. The important part is not where we create the binding, but whether the binding is represented as a fresh pair of closures or as a stable projection into the model.
# Defining the custom binding with a subscript
To avoid creating a new closure-based binding in the view body, we can move the custom read and write logic into a labeled subscript on the model. The subscript still defines get and set behavior, but that behavior belongs to the model type instead of being represented by closure values created at the call site.
@Observable
final class TeamPermissionsModel {
var grantedPermissionIDs: Set<Permission.ID> = []
var teamName = ""
subscript(isGranted permissionID: Permission.ID) -> Bool {
get {
grantedPermissionIDs.contains(permissionID)
}
set {
if newValue {
grantedPermissionIDs.insert(permissionID)
} else {
grantedPermissionIDs.remove(permissionID)
}
}
}
}
With the subscript in place, TeamPermissionsView can pass a binding to the subscript value using the same $ syntax we use for regular properties.
struct TeamPermissionsView: View {
let permissions: [Permission]
@State private var model = TeamPermissionsModel()
var body: some View {
List {
TextField("Team Name", text: $model.teamName)
ForEach(permissions) { permission in
PermissionRow(
permission: permission,
// Unrelated state updates don't trigger row body re-evaluations.
isGranted: $model[isGranted: permission.id]
)
}
}
}
}
In this setup, the PermissionRow body will only run when grantedPermissionIDs change, not when teamName is edited.
The labeled subscript makes the dependency explicit to SwiftUI. Instead of receiving an opaque binding backed by newly created closures, SwiftUI can form the binding through the model projection and the subscript access. This gives the framework enough structure to track the relationship correctly and avoid re-evaluating child views when unrelated state changes.
If you are looking to build a strong foundation in SwiftUI, my book SwiftUI Fundamentals takes a deep dive into the framework's core principles and APIs to help you understand how it works under the hood and how to use it effectively in your projects. And my new book The SwiftUI Way helps you adopt recommended patterns, avoid common pitfalls, and use SwiftUI's native tools appropriately to work with the framework rather than against it.
For more resources on Swift and SwiftUI, check out my other books and book bundles.



