Skip to main content
Many SwiftUI APIs use bindings to set up two-way communication between your application’s state and a view. The Composable Architecture provides several tools for creating bindings that establish such communication with your store.

Ad hoc bindings

The simplest tool is to create a dedicated action that changes a piece of state.
1

Define state property

@Reducer
struct Settings {
  struct State: Equatable {
    var isHapticsEnabled = true
    // ...
  }
  // ...
}
2

Define corresponding action

@Reducer
struct Settings {
  struct State: Equatable { /* ... */ }
  
  enum Action { 
    case isHapticsEnabledChanged(Bool)
    // ...
  }
  // ...
}
3

Handle the action

var body: some Reducer<State, Action> {
  Reduce { state, action in
    switch action {
    case let .isHapticsEnabledChanged(isEnabled):
      state.isHapticsEnabled = isEnabled
      return .none
    // ...
    }
  }
}
4

Derive binding in view

First, hold the store in a bindable way:
struct SettingsView: View {
  @Bindable var store: StoreOf<Settings>
  // ...
}
If targeting iOS 16 or earlier, use @Perception.Bindable instead of @Bindable.
5

Create the binding

var body: some View {
  Form {
    Toggle(
      "Haptic feedback",
      isOn: $store.isHapticsEnabled.sending(\.isHapticsEnabledChanged)
    )
  }
}

Binding actions and reducers

For screens with many controls, creating individual actions for each binding can be tedious. The library provides BindableAction and BindingReducer to eliminate boilerplate.

The problem

Consider a settings screen with many editable fields:
@Reducer
struct Settings {
  @ObservableState
  struct State {
    var digest = Digest.daily
    var displayName = ""
    var enableNotifications = false
    var isLoading = false
    var protectMyPosts = false
    var sendEmailNotifications = false
    var sendMobileNotifications = false
  }
  // ...
}
Traditionally, you’d need an action for each field:
enum Action {
  case digestChanged(Digest)
  case displayNameChanged(String)
  case enableNotificationsChanged(Bool)
  case protectMyPostsChanged(Bool)
  case sendEmailNotificationsChanged(Bool)
  case sendMobileNotificationsChanged(Bool)
}
And handle each in the reducer:
var body: some Reducer<State, Action> {
  Reduce { state, action in
    switch action {
    case let digestChanged(digest):
      state.digest = digest
      return .none
    // ... 5 more cases!
    }
  }
}
This is a lot of boilerplate.

The solution

1

Conform action to BindableAction

@Reducer
struct Settings {
  @ObservableState
  struct State { /* ... */ }
  
  enum Action: BindableAction {
    case binding(BindingAction<State>)
  }
  // ...
}
2

Add BindingReducer

var body: some Reducer<State, Action> {
  BindingReducer()
}
3

Make store bindable in view

struct SettingsView: View {
  @Bindable var store: StoreOf<Settings>
  // ...
}
4

Derive bindings with $ syntax

var body: some View {
  Form {
    TextField("Display name", text: $store.displayName)
    Toggle("Notifications", isOn: $store.enableNotifications)
    Toggle("Email notifications", isOn: $store.sendEmailNotifications)
    Toggle("Mobile notifications", isOn: $store.sendMobileNotifications)
    // ...
  }
}
That’s it! All the boilerplate is eliminated.

Observing specific bindings

You can layer additional functionality over bindings by pattern matching:

Pattern matching in reducer

var body: some Reducer<State, Action> {
  BindingReducer()
  
  Reduce { state, action in
    switch action {
    case .binding(\.displayName):
      // Validate display name
      return .none
      
    case .binding(\.enableNotifications):
      // Request authorization from UNUserNotificationCenter
      return .run { send in
        let authorized = await requestNotificationAuthorization()
        await send(.notificationAuthorizationResponse(authorized))
      }
    
    default:
      return .none
    }
  }
}

Using onChange

Alternatively, use onChange on the BindingReducer:
var body: some Reducer<State, Action> {
  BindingReducer()
    .onChange(of: \.displayName) { oldValue, newValue in
      Reduce { state, action in
        // Validate display name
        return .none
      }
    }
    .onChange(of: \.enableNotifications) { oldValue, newValue in
      Reduce { state, action in
        // Return an authorization request effect
        return .run { send in
          let authorized = await requestNotificationAuthorization()
          await send(.notificationAuthorizationResponse(authorized))
        }
      }
    }
}

Testing bindings

Binding actions can be tested just like regular actions. Instead of sending a specific action like .displayNameChanged("Blob"), you send a BindingAction:
let store = TestStore(initialState: Settings.State()) {
  Settings()
}

await store.send(\.binding.displayName, "Blob") {
  $0.displayName = "Blob"
}

await store.send(\.binding.protectMyPosts, true) {
  $0.protectMyPosts = true
}
The first argument is a key path to the binding, and the second is the new value.

Advanced: Custom bindings

You can create custom bindings that perform additional logic:
var volumeBinding: Binding<Double> {
  $store.volume.sending { newValue in
    // Custom validation or transformation
    return .volumeChanged(min(max(newValue, 0), 1))
  }
}
This allows you to intercept and transform values before they reach your reducer.

Best practices

Use BindingReducer for many bindings

When you have 3+ bindable fields, use BindingReducer instead of ad hoc bindings

Validate with onChange

Use onChange to add validation or side effects for specific fields

Test binding actions

Always test binding actions to ensure proper state mutations

Keep logic in reducer

Don’t put business logic in custom binding closures; keep it in the reducer

Common patterns

Conditional bindings

Sometimes you only want to update state when certain conditions are met:
var body: some Reducer<State, Action> {
  BindingReducer()
    .onChange(of: \.email) { oldValue, newValue in
      Reduce { state, action in
        if newValue.contains("@") {
          state.emailValid = true
        } else {
          state.emailValid = false
        }
        return .none
      }
    }
}

Derived bindings

You can derive bindings from computed properties:
extension SettingsView {
  var notificationsEnabled: Binding<Bool> {
    Binding(
      get: { store.sendEmailNotifications && store.sendMobileNotifications },
      set: { newValue in
        store.send(.binding(.set(\.sendEmailNotifications, newValue)))
        store.send(.binding(.set(\.sendMobileNotifications, newValue)))
      }
    )
  }
}

Debounced bindings

For expensive operations, debounce binding changes:
var body: some Reducer<State, Action> {
  BindingReducer()
    .onChange(of: \.searchQuery) { oldValue, newValue in
      Reduce { state, action in
        return .run { send in
          try await Task.sleep(for: .milliseconds(300))
          await send(.performSearch(newValue))
        }
        .cancellable(id: CancelID.search)
      }
    }
}

Troubleshooting

Make sure:
  1. Your store is marked with @Bindable (or @Perception.Bindable for iOS 16)
  2. Your State is marked with @ObservableState
  3. You’re using $store syntax to derive bindings
If targeting iOS 16 or earlier, use the backported version:
@Perception.Bindable var store: StoreOf<Settings>
Verify that:
  1. BindingReducer() is in your reducer’s body
  2. Your action enum conforms to BindableAction
  3. You have a case binding(BindingAction<State>) in your action enum

Build docs developers (and LLMs) love