Skip to content

Commit bf6b735

Browse files
authored
Add Reducer.onChange (#2226)
* Add `Reducer.onChange` * Inlining * wip
1 parent 461ee90 commit bf6b735

File tree

9 files changed

+138
-2
lines changed

9 files changed

+138
-2
lines changed

ComposableArchitecture.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
extension ReducerProtocol {
2+
/// Adds a reducer to run when this reducer changes the given value in state.
3+
///
4+
/// Use this operator to trigger additional logic when a value changes, like when a
5+
/// ``BindingReducer`` makes a deeper change to a struct held in ``BindingState``.
6+
///
7+
/// ```swift
8+
/// struct Settings: ReducerProtocol {
9+
/// struct State {
10+
/// @BindingState var userSettings: UserSettings
11+
/// // ...
12+
/// }
13+
///
14+
/// enum Action: BindableAction {
15+
/// case binding(BindingAction<State>)
16+
/// // ...
17+
/// }
18+
///
19+
/// var body: some ReducerProtocol<State, Action> {
20+
/// BindingReducer()
21+
/// .onChange(of: \.userSettings.isHapticFeedbackEnabled) { oldValue, newValue in
22+
/// Reduce { state, action in
23+
/// .run { send in
24+
/// // Persist new value...
25+
/// }
26+
/// }
27+
/// }
28+
/// }
29+
/// }
30+
/// ```
31+
///
32+
/// When the value changes, the new version of the closure will be called, so any captured values
33+
/// will have their values from the time that the observed value has its new value. The system
34+
/// passes the old and new observed values into the closure.
35+
///
36+
/// > Note: Take care when applying `onChange(of:)` to a reducer, as it adds an equatable check
37+
/// > for every action fed into it. Prefer applying it to leaf nodes, like ``BindingReducer``,
38+
/// > against values that are quick to equate.
39+
///
40+
/// - Parameters:
41+
/// - toValue: A closure that returns a value from the given state.
42+
/// - reducer: A reducer builder closure to run when the value changes.
43+
/// - oldValue: The old value that failed the comparison check.
44+
/// - newValue: The new value that failed the comparison check.
45+
/// - Returns: A reducer that performs the
46+
@inlinable
47+
public func onChange<V: Equatable, R: ReducerProtocol>(
48+
of toValue: @escaping (State) -> V,
49+
@ReducerBuilder<State, Action> _ reducer: @escaping (_ oldValue: V, _ newValue: V) -> R
50+
) -> _OnChangeReducer<Self, V, R> {
51+
_OnChangeReducer(base: self, toValue: toValue, reducer: reducer)
52+
}
53+
}
54+
55+
public struct _OnChangeReducer<Base: ReducerProtocol, Value: Equatable, Body: ReducerProtocol>:
56+
ReducerProtocol
57+
where Base.State == Body.State, Base.Action == Body.Action {
58+
@usableFromInline
59+
let base: Base
60+
61+
@usableFromInline
62+
let toValue: (Base.State) -> Value
63+
64+
@usableFromInline
65+
let reducer: (Value, Value) -> Body
66+
67+
@usableFromInline
68+
init(
69+
base: Base,
70+
toValue: @escaping (Base.State) -> Value,
71+
reducer: @escaping (Value, Value) -> Body
72+
) {
73+
self.base = base
74+
self.toValue = toValue
75+
self.reducer = reducer
76+
}
77+
78+
@inlinable
79+
public func reduce(into state: inout Base.State, action: Base.Action) -> EffectTask<Base.Action> {
80+
let oldValue = toValue(state)
81+
let baseEffects = self.base.reduce(into: &state, action: action)
82+
let newValue = toValue(state)
83+
return oldValue == newValue
84+
? baseEffects
85+
: .merge(baseEffects, self.reducer(oldValue, newValue).reduce(into: &state, action: action))
86+
}
87+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import ComposableArchitecture
2+
import XCTest
3+
4+
@MainActor
5+
final class OnChangeReducerTests: BaseTCATestCase {
6+
func testOnChange() async {
7+
struct Feature: ReducerProtocol {
8+
struct State: Equatable {
9+
var count = 0
10+
var description = ""
11+
}
12+
enum Action: Equatable {
13+
case incrementButtonTapped
14+
case decrementButtonTapped
15+
}
16+
var body: some ReducerProtocolOf<Self> {
17+
Reduce { state, action in
18+
switch action {
19+
case .decrementButtonTapped:
20+
state.count -= 1
21+
return .none
22+
case .incrementButtonTapped:
23+
state.count += 1
24+
return .none
25+
}
26+
}
27+
.onChange(of: \.count) { oldValue, newValue in
28+
Reduce { state, action in
29+
state.description = String(repeating: "!", count: newValue)
30+
return newValue > 1 ? .send(.decrementButtonTapped) : .none
31+
}
32+
}
33+
}
34+
}
35+
let store = TestStore(initialState: Feature.State()) { Feature() }
36+
await store.send(.incrementButtonTapped) {
37+
$0.count = 1
38+
$0.description = "!"
39+
}
40+
await store.send(.incrementButtonTapped) {
41+
$0.count = 2
42+
$0.description = "!!"
43+
}
44+
await store.receive(.decrementButtonTapped) {
45+
$0.count = 1
46+
$0.description = "!"
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)