Skip to content

Commit 01ccbd3

Browse files
committed
Merge branch 'main' into protocol-beta
2 parents 3c928f1 + 95cc28a commit 01ccbd3

File tree

15 files changed

+438
-123
lines changed

15 files changed

+438
-123
lines changed

Sources/ComposableArchitecture/Documentation.docc/Articles/Performance.md

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,102 @@ This means `AppView` does not actually need to observe any state changes. This v
7474
created a single time, whereas if we observed the store then it would re-compute every time a single
7575
thing changed in either the activity, search or profile child features.
7676

77-
If sometime in the future we do actually need some state from the store, we can create a localized
78-
"view state" struct that holds only the bare essentials of state that the view needs to do its
79-
job. For example, suppose the activity state holds an integer that represents the number of
80-
unread activities. Then we could observe changes to only that piece of state like so:
77+
If sometime in the future we do actually need some state from the store, we can start to observe
78+
only the bare essentials of state necessary for the view to do its job. For example, suppose that
79+
we need access to the currently selected tab in state:
80+
81+
```swift
82+
struct AppState {
83+
var activity: ActivityState
84+
var search: SearchState
85+
var profile: ProfileState
86+
var selectedTab: Tab
87+
enum Tab { case activity, search, profile }
88+
}
89+
```
90+
91+
Then we can observe this state so that we can construct a binding to `selectedTab` for the tab view:
92+
93+
```swift
94+
struct AppView: View {
95+
let store: Store<AppState, AppAction>
96+
97+
var body: some View {
98+
WithViewStore(self.store, observe: { $0 }) { viewStore in
99+
TabView(selection: viewStore.binding(send: AppAction.tabSelected) {
100+
ActivityView(
101+
store: self.store.scope(state: \.activity, action: AppAction.activity)
102+
)
103+
.tag(AppState.Tab.activity)
104+
SearchView(
105+
store: self.store.scope(state: \.search, action: AppAction.search)
106+
)
107+
.tag(AppState.Tab.search)
108+
ProfileView(
109+
store: self.store.scope(state: \.profile, action: AppAction.profile)
110+
)
111+
.tag(AppState.Tab.profile)
112+
}
113+
}
114+
}
115+
}
116+
```
117+
118+
However, this style of state observation is terribly inefficient since _every_ change to `AppState`
119+
will cause the view to re-compute even though the only piece of state we actually care about is
120+
the `selectedTab`. The reason we are observing too much state is because we use `observe: { $0 }`
121+
in the construction of the ``WithViewStore``, which means the view store will observe all of state.
122+
123+
To chisel away at the observed state you can provide a closure for that argument that plucks out
124+
the state the view needs. In this case the view only needs a single field:
125+
126+
```swift
127+
WithViewStore(self.store, observe: \.selectedTab) { viewStore in
128+
TabView(selection: viewStore.binding(send: AppAction.tabSelected) {
129+
// ...
130+
}
131+
}
132+
```
133+
134+
In the future, the view may need access to more state. For example, suppose `ActivityState` holds
135+
onto an `unreadCount` integer to represent how many new activities you have. There's no need to
136+
observe _all_ of `ActivityState` to get access to this one field. You can observe just the one
137+
field.
138+
139+
Technically you can do this by mapping your state into a tuple, but because tuples are not
140+
`Equatable` you will need to provide an explicit `removeDuplicates` argument:
141+
142+
```swift
143+
WithViewStore(
144+
self.store,
145+
observe: { (selectedTab: $0.selectedTab, unreadActivityCount: $0.activity.unreadCount) },
146+
removeDuplicates: ==
147+
) { viewStore in
148+
TabView(selection: viewStore.binding(\.selectedTab, send: AppAction.tabSelected) {
149+
ActivityView(
150+
store: self.store.scope(state: \.activity, action: AppAction.activity)
151+
)
152+
.tag(AppState.Tab.activity)
153+
.badge("\(viewStore.state)")
154+
155+
// ...
156+
}
157+
}
158+
```
159+
160+
Alternatively, and recommended, you can introduce a lightweight, equatable `ViewState` struct
161+
nested inside your view whose purpose is to transform the `Store`'s full state into the bare
162+
essentials of what the view needs:
81163

82164
```swift
83165
struct AppView: View {
84166
let store: StoreOf<AppReducer>
85167

86-
struct ViewState {
168+
struct ViewState: Equatable {
169+
let selectedTab: AppState.Tab
87170
let unreadActivityCount: Int
88171
init(state: AppReducer.State) {
172+
self.selectedTab = state.selectedTab
89173
self.unreadActivityCount = state.activity.unreadCount
90174
}
91175
}
@@ -106,24 +190,25 @@ struct AppView: View {
106190
}
107191
```
108192

109-
Now the `AppView` will re-compute its body only when `activity.unreadCount` changes. In particular,
110-
no changes to the search or profile features will cause the view to re-compute, and that greatly
111-
reduces how often the view must re-compute.
193+
This gives you maximum flexibilty in the future for adding new fields to `ViewState` without making
194+
your view convoluated.
112195

113196
This technique for reducing view re-computations is most effective towards the root of your app
114197
hierarchy and least effective towards the leaf nodes of your app. Root features tend to hold lots
115198
of state that its view does not need, such as child features, and leaf features tend to only hold
116199
what's necessary. If you are going to employ this technique you will get the most benefit by
117-
applying it to views closer to the root.
200+
applying it to views closer to the root. At leaf features and views that need access to most
201+
of the state, it is fine to continue using `observe: { $0 }` to observe all of the state in the
202+
store.
118203

119204
### CPU intensive calculations
120205

121206
Reducers are run on the main thread and so they are not appropriate for performing intense CPU
122207
work. If you need to perform lots of CPU-bound work, then it is more appropriate to use an
123-
``Effect``, which will operate in the cooperative thread pool, and then send it's output back into
124-
the system via an action. You should also make sure to perform your CPU intensive work in a
125-
cooperative manner by periodically suspending with `Task.yield()` so that you do not block a thread
126-
in the cooperative pool for too long.
208+
``Effect``, which will operate in the cooperative thread pool, and then send actions back into the
209+
system. You should also make sure to perform your CPU intensive work in a cooperative manner by
210+
periodically suspending with `Task.yield()` so that you do not block a thread in the cooperative
211+
pool for too long.
127212

128213
So, instead of performing intense work like this in your reducer:
129214

Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/EffectDeprecations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement
2828

2929
### Combine integration
3030

31+
- ``Effect/Output``
3132
- ``Effect/init(_:)``
3233
- ``Effect/init(value:)``
3334
- ``Effect/init(error:)``

Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,27 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement
88

99
## Topics
1010

11-
### View state
11+
### `WithViewStore`
12+
13+
- ``WithViewStore/init(_:content:file:line:)-1gjbi``
14+
- ``WithViewStore/init(_:content:file:line:)-2uj44``
15+
- ``WithViewStore/init(_:content:file:line:)-5vj3w``
16+
- ``WithViewStore/init(_:content:file:line:)-5zsmz``
17+
- ``WithViewStore/init(_:content:file:line:)-7kai``
18+
- ``WithViewStore/init(_:file:line:content:)-4xog0``
19+
- ``WithViewStore/init(_:file:line:content:)-55smh``
20+
- ``WithViewStore/init(_:file:line:content:)-7qkc1``
21+
- ``WithViewStore/init(_:file:line:content:)-8b21b``
22+
- ``WithViewStore/init(_:file:line:content:)-9b6e2``
23+
- ``WithViewStore/init(_:removeDuplicates:content:file:line:)-1lyhl``
24+
- ``WithViewStore/init(_:removeDuplicates:content:file:line:)-35xje``
25+
- ``WithViewStore/init(_:removeDuplicates:content:file:line:)-8zzun``
26+
- ``WithViewStore/init(_:removeDuplicates:content:file:line:)-9atby``
27+
- ``WithViewStore/init(_:removeDuplicates:file:line:content:)``
28+
- ``WithViewStore/Action``
29+
- ``WithViewStore/State``
30+
31+
### View State
1232

1333
- ``ActionSheetState``
1434

Sources/ComposableArchitecture/Effect.swift

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import Foundation
33
import SwiftUI
44
import XCTestDynamicOverlay
55

6-
/// The ``Effect`` type encapsulates a unit of work that can be kicked off from the reducer into the
7-
/// outside world, and can feed data back to the ``Store`` and into the reducer. It is the perfect
8-
/// place to do side effects, such as network requests, saving/loading from disk, creating timers,
9-
/// interacting with dependencies, and more.
6+
/// The ``Effect`` type encapsulates a unit of work that can be run in the outside world, and can
7+
/// feed actions back to the ``Store``. It is the perfect place to do side effects, such as network
8+
/// requests, saving/loading from disk, creating timers, interacting with dependencies, and more.
109
///
1110
/// Effects are returned from reducers so that the ``Store`` can perform the effects after the
1211
/// reducer is done running.
@@ -33,12 +32,12 @@ import XCTestDynamicOverlay
3332
/// > This is only an issue if using the Combine interface of ``Effect`` as mentioned above. If you
3433
/// you are using Swift's concurrency tools and the `.task`, `.run` and `.fireAndForget` functions
3534
/// on ``Effect``, then threading is automatically handled for you.
36-
public struct Effect<Output, Failure: Error> {
35+
public struct Effect<Action, Failure: Error> {
3736
@usableFromInline
3837
enum Operation {
3938
case none
40-
case publisher(AnyPublisher<Output, Failure>)
41-
case run(TaskPriority? = nil, @Sendable (Send<Output>) async -> Void)
39+
case publisher(AnyPublisher<Action, Failure>)
40+
case run(TaskPriority? = nil, @Sendable (Send<Action>) async -> Void)
4241
}
4342

4443
@usableFromInline
@@ -116,8 +115,8 @@ extension Effect where Failure == Never {
116115
/// - Returns: An effect wrapping the given asynchronous work.
117116
public static func task(
118117
priority: TaskPriority? = nil,
119-
operation: @escaping @Sendable () async throws -> Output,
120-
catch handler: (@Sendable (Error) async -> Output)? = nil,
118+
operation: @escaping @Sendable () async throws -> Action,
119+
catch handler: (@Sendable (Error) async -> Action)? = nil,
121120
file: StaticString = #file,
122121
fileID: StaticString = #fileID,
123122
line: UInt = #line
@@ -175,7 +174,7 @@ extension Effect where Failure == Never {
175174
/// }
176175
/// ```
177176
///
178-
/// Then you could attach to it in a `run` effect by using `for await` and sending each output of
177+
/// Then you could attach to it in a `run` effect by using `for await` and sending each action of
179178
/// the stream back into the system:
180179
///
181180
/// ```swift
@@ -203,8 +202,8 @@ extension Effect where Failure == Never {
203202
/// - Returns: An effect wrapping the given asynchronous work.
204203
public static func run(
205204
priority: TaskPriority? = nil,
206-
operation: @escaping @Sendable (Send<Output>) async throws -> Void,
207-
catch handler: (@Sendable (Error, Send<Output>) async -> Void)? = nil,
205+
operation: @escaping @Sendable (Send<Action>) async throws -> Void,
206+
catch handler: (@Sendable (Error, Send<Action>) async -> Void)? = nil,
208207
file: StaticString = #file,
209208
fileID: StaticString = #fileID,
210209
line: UInt = #line
@@ -448,11 +447,11 @@ extension Effect {
448447

449448
/// Transforms all elements from the upstream effect with a provided closure.
450449
///
451-
/// - Parameter transform: A closure that transforms the upstream effect's output to a new output.
450+
/// - Parameter transform: A closure that transforms the upstream effect's action to a new action.
452451
/// - Returns: A publisher that uses the provided closure to map elements from the upstream effect
453452
/// to new elements that it then publishes.
454453
@inlinable
455-
public func map<T>(_ transform: @escaping (Output) -> T) -> Effect<T, Failure> {
454+
public func map<T>(_ transform: @escaping (Action) -> T) -> Effect<T, Failure> {
456455
switch self.operation {
457456
case .none:
458457
return .none
@@ -462,11 +461,9 @@ extension Effect {
462461
return .init(
463462
operation: .run(priority) { send in
464463
await operation(
465-
.init(
466-
send: { output in
467-
send(transform(output))
468-
}
469-
)
464+
Send { action in
465+
send(transform(action))
466+
}
470467
)
471468
}
472469
)

Sources/ComposableArchitecture/Effects/Cancellation.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ extension Effect {
3939
()
4040
-> Publishers.HandleEvents<
4141
Publishers.PrefixUntilOutput<
42-
AnyPublisher<Output, Failure>, PassthroughSubject<Void, Never>
42+
AnyPublisher<Action, Failure>, PassthroughSubject<Void, Never>
4343
>
4444
> in
4545
cancellablesLock.lock()

Sources/ComposableArchitecture/Effects/Publisher.swift

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import Combine
55
@available(tvOS, deprecated: 9999.0)
66
@available(watchOS, deprecated: 9999.0)
77
extension Effect: Publisher {
8+
public typealias Output = Action
9+
810
public func receive<S: Combine.Subscriber>(
911
subscriber: S
10-
) where S.Input == Output, S.Failure == Failure {
12+
) where S.Input == Action, S.Failure == Failure {
1113
self.publisher.subscribe(subscriber)
1214
}
1315

14-
var publisher: AnyPublisher<Output, Failure> {
16+
var publisher: AnyPublisher<Action, Failure> {
1517
switch self.operation {
1618
case .none:
1719
return Empty().eraseToAnyPublisher()
@@ -75,7 +77,7 @@ extension Effect {
7577
@available(macOS, deprecated: 9999.0, message: "Wrap the value in 'Effect.task', instead.")
7678
@available(tvOS, deprecated: 9999.0, message: "Wrap the value in 'Effect.task', instead.")
7779
@available(watchOS, deprecated: 9999.0, message: "Wrap the value in 'Effect.task', instead.")
78-
public init(value: Output) {
80+
public init(value: Action) {
7981
self.init(Just(value).setFailureType(to: Failure.self))
8082
}
8183

@@ -147,7 +149,7 @@ extension Effect {
147149
@available(tvOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.")
148150
@available(watchOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.")
149151
public static func future(
150-
_ attemptToFulfill: @escaping (@escaping (Result<Output, Failure>) -> Void) -> Void
152+
_ attemptToFulfill: @escaping (@escaping (Result<Action, Failure>) -> Void) -> Void
151153
) -> Self {
152154
Deferred { Future(attemptToFulfill) }.eraseToEffect()
153155
}
@@ -181,7 +183,7 @@ extension Effect {
181183
@available(macOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.")
182184
@available(tvOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.")
183185
@available(watchOS, deprecated: 9999.0, message: "Use 'Effect.task', instead.")
184-
public static func result(_ attemptToFulfill: @escaping () -> Result<Output, Failure>) -> Self {
186+
public static func result(_ attemptToFulfill: @escaping () -> Result<Action, Failure>) -> Self {
185187
Deferred { Future { $0(attemptToFulfill()) } }.eraseToEffect()
186188
}
187189

@@ -247,7 +249,7 @@ extension Effect {
247249
// iOS 13.3, but to remain compatible with iOS 13.2 and higher we need to do a little
248250
// trickery to make sure the deferred publisher completes.
249251
let dependencies = DependencyValues.current
250-
return Deferred { () -> Publishers.CompactMap<Result<Output?, Failure>.Publisher, Output> in
252+
return Deferred { () -> Publishers.CompactMap<Result<Action?, Failure>.Publisher, Action> in
251253
DependencyValues.$current.withValue(dependencies) {
252254
try? work()
253255
}
@@ -297,7 +299,7 @@ extension Effect where Failure == Error {
297299
watchOS, deprecated: 9999.0,
298300
message: "Throw and catch errors directly in 'Effect.task' and 'Effect.run', instead."
299301
)
300-
public static func catching(_ work: @escaping () throws -> Output) -> Self {
302+
public static func catching(_ work: @escaping () throws -> Action) -> Self {
301303
.future { $0(Result { try work() }) }
302304
}
303305
}

Sources/ComposableArchitecture/Effects/Publisher/Throttling.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ extension Effect {
2525
return .none
2626
case .publisher, .run:
2727
return self.receive(on: scheduler)
28-
.flatMap { value -> AnyPublisher<Output, Failure> in
28+
.flatMap { value -> AnyPublisher<Action, Failure> in
2929
throttleLock.lock()
3030
defer { throttleLock.unlock() }
3131

@@ -35,7 +35,7 @@ extension Effect {
3535
return Just(value).setFailureType(to: Failure.self).eraseToAnyPublisher()
3636
}
3737

38-
let value = latest ? value : (throttleValues[id] as! Output? ?? value)
38+
let value = latest ? value : (throttleValues[id] as! Action? ?? value)
3939
throttleValues[id] = value
4040

4141
guard throttleTime.distance(to: scheduler.now) < interval else {

Sources/ComposableArchitecture/Effects/Publisher/Timer.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ extension Effect where Failure == Never {
9595
tolerance: S.SchedulerTimeType.Stride? = nil,
9696
on scheduler: S,
9797
options: S.SchedulerOptions? = nil
98-
) -> Self where S.SchedulerTimeType == Output {
98+
) -> Self where S.SchedulerTimeType == Action {
9999
Publishers.Timer(every: interval, tolerance: tolerance, scheduler: scheduler, options: options)
100100
.autoconnect()
101101
.setFailureType(to: Failure.self)
@@ -129,7 +129,7 @@ extension Effect where Failure == Never {
129129
tolerance: S.SchedulerTimeType.Stride? = nil,
130130
on scheduler: S,
131131
options: S.SchedulerOptions? = nil
132-
) -> Self where S.SchedulerTimeType == Output {
132+
) -> Self where S.SchedulerTimeType == Action {
133133
self.timer(
134134
id: ObjectIdentifier(id),
135135
every: interval,

Sources/ComposableArchitecture/Internal/Create.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,18 +171,18 @@ extension Publishers.Create.Subscription: CustomStringConvertible {
171171

172172
extension Effect {
173173
public struct Subscriber {
174-
private let _send: (Output) -> Void
174+
private let _send: (Action) -> Void
175175
private let _complete: (Subscribers.Completion<Failure>) -> Void
176176

177177
init(
178-
send: @escaping (Output) -> Void,
178+
send: @escaping (Action) -> Void,
179179
complete: @escaping (Subscribers.Completion<Failure>) -> Void
180180
) {
181181
self._send = send
182182
self._complete = complete
183183
}
184184

185-
public func send(_ value: Output) {
185+
public func send(_ value: Action) {
186186
self._send(value)
187187
}
188188

0 commit comments

Comments
 (0)