@Bindable var model에 $model.name 한 줄이면 끝났다. 바인딩이 모델 프로퍼티에 직접 대입했기 때문이다. TCA에서 상태를 바꿀 수 있는 곳은 리듀서뿐인데, TextField는 쓰기가 되는 Binding을 원한다. 해법은 바인딩이 대입하는 대신 액션을 보내게 만드는 것이다. 오늘은 그 방법 두 가지(ad hoc · BindableAction)와, 바뀐 값에 반응하기, 포커스, 테스트까지 간다.오늘의 목표 · 25분
- 컴파일되지 않는
$store.text를.sending(\.textChanged)또는BindableAction으로 고친다. - 필드마다 있던
xxxChanged액션들을case binding(BindingAction<State>)하나와BindingReducer()로 줄인다. - 특정 필드가 바뀔 때의 검증을
case .binding(\.field)와.onChange(of:)중 맞는 쪽으로 넣고,BindingReducer위치 실수를 찾는다. @FocusState를 store 상태와.bind(_:to:)로 동기화하고, 포커스 로직을 TestStore로 확인한다.
1. 바인딩은 "쓰기" 대신 액션을 보낸다 🔥 핵심
Binding은 get과 set 한 쌍이다. SwiftUI @Observable에서는 set이 곧 model.name = newValue다. TCA에서는 뷰가 상태를 직접 바꿀 수 없으니, set이 store에 액션을 보내는 일이 되어야 한다. 이 원칙 하나가 오늘 배울 모든 API의 이유다.
@Observable
final class SettingsModel {
var isHapticsEnabled = true
}
struct SettingsScreen: View {
@Bindable var model: SettingsModel
var body: some View {
Toggle("햅틱", isOn: $model.isHapticsEnabled)
}
}struct HapticsView: View {
@Bindable var store: StoreOf<Haptics> // Action이 BindableAction이 아니다
var body: some View {
Toggle("햅틱", isOn: $store.isHapticsEnabled)
// error: cannot assign through dynamic lookup property: subscript is get-only
}
}가장 단순한 해법은 ad hoc 바인딩이다. 필드를 바꾸는 전용 액션을 만들고, .sending(\.액션)으로 "set이 오면 이 액션을 보내라"고 알려 준다(TCA 문서 · Bindings).
@Reducer
struct Haptics {
@ObservableState
struct State: Equatable {
var isHapticsEnabled = true
}
enum Action {
case isHapticsEnabledChanged(Bool)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .isHapticsEnabledChanged(isOn):
state.isHapticsEnabled = isOn
return .none
}
}
}
}
struct HapticsToggleView: View {
@Bindable var store: StoreOf<Haptics> // $store를 쓰려면 @Bindable
var body: some View {
Toggle(
"햅틱",
isOn: $store.isHapticsEnabled.sending(\.isHapticsEnabledChanged)
)
}
}Day 2처럼 let store로 받으면 $store 자체가 존재하지 않는다. 바인딩을 만들 뷰는 SwiftUI의 @Bindable var store로 받는다 — SwiftUI 코스 Day 5에서 @Observable 모델에 쓰던 그 래퍼다. iOS 16 이하를 지원하면 문서대로 @Perception.Bindable을 쓴다.
2. BindableAction + BindingReducer — 필드가 많을 때 🔥 핵심
설정 화면에 필드가 여섯 개면 ad hoc 방식은 액션 여섯 개, 리듀서 case 여섯 개가 된다. 모두 "받은 값을 그 필드에 넣는다"는 똑같은 일이다. 문서도 이렇게 말한다.
"This is a lot of boilerplate for something that should be simple. Luckily, we can dramatically eliminate this boilerplate using BindableAction and BindingReducer." — Bindings
단순해야 할 일에 코드가 너무 많으니, BindableAction과 BindingReducer로 그 반복을 크게 없앤다는 뜻이다.
방법은 세 단계다. ① Action을 BindableAction으로 만들고 case binding(BindingAction<State>) 하나를 둔다. ② body에 BindingReducer()를 넣는다. ③ 뷰는 $store.필드를 그대로 쓴다.
enum Digest: String, CaseIterable, Hashable {
case daily, weekly, off
}
@Reducer
struct ProfileSettings {
@ObservableState
struct State: Equatable {
var digest = Digest.daily
var displayName = ""
var enableNotifications = false
var protectMyPosts = false
}
enum Action: BindableAction { // ①
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
BindingReducer() // ②
}
}
struct ProfileSettingsView: View {
@Bindable var store: StoreOf<ProfileSettings>
var body: some View {
Form { // ③
TextField("표시 이름", text: $store.displayName)
Toggle("알림", isOn: $store.enableNotifications)
Toggle("내 글 보호", isOn: $store.protectMyPosts)
Picker("요약 메일", selection: $store.digest) {
ForEach(Digest.allCases, id: \.self) { Text($0.rawValue) }
}
}
}
}뷰 코드는 SwiftUI 폼과 글자 그대로 같다. 차이는 뒤에서 일어난다. 소스를 따라가면 BindableAction인 Store에는 set 가능한 서브스크립트가 생기고, 그 setter가 .binding(.set(keyPath, newValue)) 액션을 보낸다. BindingReducer는 그 액션에서 key path와 값을 꺼내 상태에 대입한다(Binding+Observation.swift, BindingReducer.swift).
| 비교 | SwiftUI (@Observable) | TCA ad hoc | TCA BindableAction |
|---|---|---|---|
| 뷰 코드 | $model.name | $store.name.sending(\.nameChanged) | $store.name |
| 필드 추가 비용 | 프로퍼티 1줄 | 프로퍼티 + 액션 + case | 프로퍼티 1줄 |
| 변경이 액션으로 남나 | 아니오 | 예 (필드별 이름) | 예 (.binding(.set(\.name, …))) |
| 조건 | — | 없음 | 값이 Equatable & Sendable, BindingReducer() 필수 |
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .binding:
return .none // 아무도 state에 대입하지 않는다
}
}
}
// DEBUG 런타임 경고:
// A binding action sent from a store was not handled. ...
// To fix this, invoke "BindingReducer()" from your feature reducer's "body".이 경고 문구는 소스의 BindableActionDebugger가 낸다. 바인딩 액션이 처리되지 않은 채 사라지면 DEBUG 빌드에서 알려 준다. 보라색 경고가 보이면 body부터 확인한다.
3. 바뀐 값에 반응하기 — case .binding(\.x)와 onChange
"최대값을 줄이면 슬라이더 값도 그 안으로 끌어내린다" 같은 추가 로직은 BindingReducer() 뒤에 Reduce를 두고, 특정 key path의 바인딩 액션을 패턴 매칭한다. 공식 케이스 스터디 Bindings-Forms의 방식이다.
@Reducer
struct SliderForm {
@ObservableState
struct State: Equatable {
var sliderValue = 5.0
var stepCount = 10
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case resetButtonTapped
}
var body: some ReducerOf<Self> {
BindingReducer() // 먼저 대입
Reduce { state, action in // 그다음 보정
switch action {
case .binding(\.stepCount):
state.sliderValue = min(state.sliderValue, Double(state.stepCount))
return .none
case .binding:
return .none
case .resetButtonTapped:
state = State()
return .none
}
}
}
}onChange — "값이 실제로 달라졌을 때"만
두 번째 방법은 BindingReducer()에 .onChange(of:)를 붙이는 것이다. 1.26.2 소스의 _OnChangeReducer는 붙은 리듀서를 돌리기 전후 값을 비교해서, 달라졌을 때만 클로저를 부른다. 클로저는 옛 값과 inout state를 받고 이펙트를 돌려준다.
@Reducer
struct NicknameForm {
@ObservableState
struct State: Equatable {
var nickname = ""
var isNicknameValid = false
}
enum Action: BindableAction {
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
BindingReducer()
.onChange(of: \.nickname) { oldValue, state in
state.isNicknameValid = (2...12).contains(state.nickname.count)
return .none // 필요하면 .run { … }도 가능
}
}
}Bindings 아티클은 .onChange(of: \.displayName) { oldValue, newValue in … } 형태를 보여 준다. 1.26.2 소스에서 그 형태(리듀서를 돌려주는 오버로드)는 "Use 'onChange' that directly returns an 'EffectOf<Feature>' instead" 메시지로 soft-deprecated다. 새 코드는 위처럼 { oldValue, state in … return 이펙트 }로 쓴다.
"The onChange operator is only capable of detecting changes made by the reducer it is directly attached to." — OnChange.swift 문서 주석
BindingReducer()에 붙인 onChange는 바인딩으로 생긴 변화만 본다. "초기화" 버튼 액션이 nickname을 비워도 이 클로저는 불리지 않는다. 같은 문서는 매 액션마다 비교가 한 번씩 들어가니 BindingReducer 같은 잎 노드에, 비교가 싼 값에 붙이라고 권한다.
| 반응 방법 | 언제 불리나 | 어울리는 일 |
|---|---|---|
SwiftUI .onChange(of: model.nickname) | 뷰가 떠 있고 값이 바뀔 때 | 뷰 전용 반응. 로직이 뷰에 남는다 |
TCA case .binding(\.nickname) | 그 필드의 바인딩 액션마다 (같은 값이어도) | 다른 필드 보정, 요청 시작 |
TCA .onChange(of: \.nickname) | 붙은 리듀서가 값을 실제로 바꿨을 때 | 검증·저장처럼 "값이 달라졌을 때만" 할 일 |
4. 포커스 — @FocusState와 store 상태 잇기
@FocusState는 뷰 안에서만 쓸 수 있는 래퍼라 store 상태가 될 수 없다. 순수 SwiftUI에서는 "빈 칸으로 포커스 옮기기" 로직을 뷰 버튼 안에 쓴다(SwiftUI 코스 Day 11 2부).
struct SignInScreen: View {
enum Field: Hashable { case username, password }
@State private var username = ""
@State private var password = ""
@FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("아이디", text: $username)
.focused($focusedField, equals: .username)
SecureField("비밀번호", text: $password)
.focused($focusedField, equals: .password)
Button("로그인") {
if username.isEmpty { focusedField = .username }
else if password.isEmpty { focusedField = .password }
}
}
}
}TCA에서는 포커스를 평범한 상태(var focusedField: Field?)로 두고, 뷰의 @FocusState와 .bind(_:to:)로 양방향 동기화한다. 케이스 스터디 FocusState 그대로다. bind는 swift-navigation의 도구로, 처음 나타날 때 한 번 맞추고 이후 양쪽 onChange로 서로를 따라가게 한다(Bind.swift).
@Reducer
struct SignIn {
@ObservableState
struct State: Equatable {
var focusedField: Field?
var password = ""
var username = ""
enum Field: Hashable {
case username, password
}
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case signInButtonTapped
}
var body: some ReducerOf<Self> {
BindingReducer()
Reduce { state, action in
switch action {
case .binding:
return .none
case .signInButtonTapped:
if state.username.isEmpty {
state.focusedField = .username
} else if state.password.isEmpty {
state.focusedField = .password
}
return .none
}
}
}
}
struct SignInView: View {
@Bindable var store: StoreOf<SignIn>
@FocusState var focusedField: SignIn.State.Field?
var body: some View {
Form {
TextField("아이디", text: $store.username)
.focused($focusedField, equals: .username)
SecureField("비밀번호", text: $store.password)
.focused($focusedField, equals: .password)
Button("로그인") { store.send(.signInButtonTapped) }
}
.bind($store.focusedField, to: $focusedField) // 양방향 동기화
}
}얻는 것은 포커스 로직의 테스트다. 사용자가 아이디 칸을 탭하면 @FocusState → bind → $store.focusedField 바인딩 액션으로 store에도 기록되고, 리듀서가 focusedField를 바꾸면 반대로 키보드가 옮겨 간다. 대가는 뷰에 선언 두 개(@FocusState와 .bind)가 더 붙는 것이다.
5. 바인딩 테스트와 흔한 실수
바인딩 액션도 보통 액션처럼 TestStore로 보낸다. 문서 형식은 store.send(\.binding.필드, 값)이다. 아래는 4절 포커스 로직을 뷰 없이 확인한다. TestStore 자체는 Day 8에서 자세히 본다.
import Testing
@MainActor
@Test func signInFocusTest() async {
let store = TestStore(initialState: SignIn.State()) {
SignIn()
}
await store.send(\.binding.username, "blob") {
$0.username = "blob"
}
await store.send(.signInButtonTapped) {
$0.focusedField = .password // 비밀번호가 비었으니 그쪽으로
}
}| 증상 | 원인 | 고치기 |
|---|---|---|
subscript is get-only 컴파일 에러 | Action이 BindableAction이 아님 | .sending(\.액션) 또는 BindableAction 채택 |
$store를 찾을 수 없음 | let store로 받음 | @Bindable var store |
| 입력해도 값이 그대로 + 보라색 경고 | BindingReducer() 누락 | body 맨 위에 추가 |
| 보정 로직이 한 박자 늦다 | BindingReducer가 Reduce 아래 | 순서를 바꾼다 (3절 그림) |
$store.x만 안 된다 | 값 타입이 Equatable/Sendable이 아님 | 타입에 준수를 추가하거나 ad hoc 액션 |
✅ 복습 · 5분
BindableAction이 아닌 기능에서 $store.text가 컴파일되지 않는 이유는?
Binding은 set이 필요한데, TCA에서 상태를 바꾸는 유일한 길은 액션이다. Action이 BindableAction이면 Store에 set 가능한 서브스크립트가 생겨 set이 .binding(.set(…)) 액션으로 바뀌고, 아니면 .sending(\.textChanged)로 어떤 액션을 보낼지 직접 알려 줘야 한다.case .binding(\.nickname)과 .onChange(of: \.nickname)의 차이는?
onChange는 붙은 리듀서를 돌리기 전후 값을 비교해 실제로 달라졌을 때만 실행되고, 붙은 리듀서가 만든 변화만 본다. 다른 필드를 보정하거나 요청을 시작하면 패턴 매칭, "바뀌었을 때만 검증·저장"이면 onChange가 어울린다.@Reducer
struct SliderFormFlipped {
@ObservableState
struct State: Equatable {
var sliderValue = 5.0
var stepCount = 10
}
enum Action: BindableAction {
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .binding(\.stepCount):
state.sliderValue = min(state.sliderValue, Double(state.stepCount))
return .none
case .binding:
return .none
}
}
BindingReducer()
}
}🔮 코드 예측: 위 기능에서 스테퍼로 stepCount를 10 → 3으로 바꾸면 sliderValue는?
Reduce가 먼저 실행될 때 state.stepCount는 아직 10이라 min(5, 10) = 5이고, 그 뒤 BindingReducer가 stepCount에 3을 넣는다. 슬라이더 값이 최대 3을 넘은 상태가 된다. 아래 테스트가 통과한다는 것으로 확인할 수 있다 — exhaustive 검사라 sliderValue가 바뀌었다면 실패한다.
import Testing
@MainActor
@Test func flippedOrderTest() async {
let store = TestStore(initialState: SliderFormFlipped.State()) {
SliderFormFlipped()
}
await store.send(\.binding.stepCount, 3) {
$0.stepCount = 3 // sliderValue는 5.0 그대로
}
}🔁 Day 3 복습: 검색어를 $store.query로 바인딩하면서, 타이핑이 0.3초 멈추면 검색하려면 코드를 어디에 어떻게 넣나?
BindingReducer() 뒤 Reduce에서 case .binding(\.query):를 잡고, Day 3의 디바운스 이펙트를 돌려준다. return .run { [query = state.query] send in try await clock.sleep(for: .milliseconds(300)); await send(.searchResponse(…)) }.cancellable(id: CancelID.search, cancelInFlight: true). state는 inout이라 캡처 리스트로 값을 복사해 넘겨야 하고, cancelInFlight가 이전 글자의 검색을 취소한다.🛠 미니 과제 (선택 · 10분)
가입 폼을 만든다. Xcode 프로젝트에 TCA 패키지를 추가했다고 가정하고, Preview(또는 TestStore)로 확인한다.
email·password·agreesToTerms를BindableAction으로 바인딩한다(필드별 액션 금지).password가 바뀔 때만isPasswordLong(8자 이상)을 갱신한다 —onChange사용.- "가입" 버튼을 누르면 비어 있는 첫 칸(email → password 순)으로 포커스를 옮긴다.
@Reducer
struct SignUp {
@ObservableState
struct State: Equatable {
var email = ""
var password = ""
var agreesToTerms = false
var isPasswordLong = false
}
enum Action {
case signUpButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .signUpButtonTapped:
// TODO: 빈 칸으로 포커스
return .none
}
}
}
}
struct SignUpView: View {
let store: StoreOf<SignUp>
var body: some View {
Form {
Text(store.email) // TODO: TextField · SecureField · Toggle로 바꾸기
Button("가입") { store.send(.signUpButtonTapped) }
.disabled(!store.agreesToTerms)
}
}
}
#Preview {
SignUpView(store: Store(initialState: SignUp.State()) { SignUp() })
}확인 방법: Preview에서 약관을 켜고 빈 폼으로 "가입"을 누르면 이메일 칸에 커서가 가는지, 이메일을 채우고 다시 누르면 비밀번호 칸으로 가는지, 비밀번호를 8자 넘게 치면 "충분" 표시가 켜지는지 본다.
정답 코드 보기
@Reducer
struct SignUpSolution {
@ObservableState
struct State: Equatable {
var email = ""
var password = ""
var agreesToTerms = false
var isPasswordLong = false
var focus: Field?
enum Field: Hashable { case email, password }
}
enum Action: BindableAction {
case binding(BindingAction<State>)
case signUpButtonTapped
}
var body: some ReducerOf<Self> {
BindingReducer()
.onChange(of: \.password) { _, state in
state.isPasswordLong = state.password.count >= 8
return .none
}
Reduce { state, action in
switch action {
case .binding:
return .none
case .signUpButtonTapped:
if state.email.isEmpty {
state.focus = .email
} else if state.password.isEmpty {
state.focus = .password
}
return .none
}
}
}
}
struct SignUpSolutionView: View {
@Bindable var store: StoreOf<SignUpSolution>
@FocusState var focus: SignUpSolution.State.Field?
var body: some View {
Form {
TextField("이메일", text: $store.email)
.focused($focus, equals: .email)
SecureField("비밀번호", text: $store.password)
.focused($focus, equals: .password)
Text(store.isPasswordLong ? "충분" : "8자 이상")
Toggle("약관 동의", isOn: $store.agreesToTerms)
Button("가입") { store.send(.signUpButtonTapped) }
.disabled(!store.agreesToTerms)
}
.bind($store.focus, to: $focus)
}
}
#Preview {
SignUpSolutionView(store: Store(initialState: SignUpSolution.State()) { SignUpSolution() })
}