You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Introduce ViewStore for scoped component stores (#15)
This PR introduces the ability to create scoped stores for sub-components, called `ViewStores`. `ViewStores` are conceptually like bindings for stores, except that they expose the store API, instead of a binding API. This approach is inspired by the component mapping pattern from Elm.
We also update the implementation of Store so that fx are run immediately instead of being joined on main queue. This is to avoid adding delay when intercepting child actions and then sending them back up as fx.
## Changes
- Introduce `ModelProtocol`, which implements an `update(state:action:environment)` static function.
- Model protocol allows us to treat action and environment as associatedtypes, which simplifies many of our other type signatures.
- Introduce `StoreProtocol`, a protocol that describes a kind of store.
- Introduce `ViewStore<Model: ModelProtocol>`, which implements `StoreProtocol`
- Implement `StoreProtocol` for `Store`
- Introduce `CursorProtocol`, which describes how to map from one domain to another, and provides a convenience function for updating child components.
- Replace `.binding` with generic Binding intializer for any StoreProtocol
- Add new tests
## Breaking changes
- Fx are now run immediately, meaning you will have to manually join asyncronous fx to main queue with `.receive(on: DispatchQueue.main)`
- Store requires that state implement `ModelProtocol`. This allows us to simplify the signatures of many other APIs
- Update type signature changes from `Update<State, Action>` to `Update<Model: ModelProtocol>`.
- Store type signature changes from `Store<State, Action, Environment>` to `Store<Model: ModelProtocol>`
- Store initializer changes from `Store.init(update:state:environment:)` to `Store.init(state:environment:)`. Now that state conforms to `ModelProtocol`, you don't have to explicitly pass in the update function as a closure. We can just call the protocol implementation.
- `store.binding` has been removed in favor of `Binding(store:get:send:)`
A simple Elm-like Store for SwiftUI, based on [ObservableObject](https://developer.apple.com/documentation/combine/observableobject).
4
4
5
-
ObservableStore helps you craft more reliable apps by centralizing all of your application state into one place, and making all changes to state deterministic. If you’ve ever used [Elm](https://guide.elm-lang.org/architecture/) or [Redux](https://redux.js.org/), you get the gist. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order.
5
+
ObservableStore helps you craft more reliable apps by centralizing all of your application state into one place, and giving you a deterministic system for managing state changes and side-effects. All state updates happen through actions passed to an update function. This guarantees your application will produce exactly the same state, given the same actions in the same order. If you’ve ever used [Elm](https://guide.elm-lang.org/architecture/) or [Redux](https://redux.js.org/), you get the gist.
6
6
7
-
Because `Store` is an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), it can be used anywhere in SwiftUI that ObservableObject would be used.
7
+
Because `Store` is an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), and can be used anywhere in SwiftUI that ObservableObject would be used.
8
8
9
-
Store is meant to be used as part of a single app-wide, or major-view-wide component. It deliberately does not solve for nested components or nested stores. Following Elm, deeply nested components are avoided. Instead, it is designed for apps that use a single store, or perhaps one store per major view. Instead of decomposing an app into many stateful components, ObservableStore favors decomposing an app into many stateless views that share the same store and actions. Sub-views can be passed data through bare properties of `store.state`, or bindings, which can be created with `store.binding`, or share the store globally, through [`EnvironmentObject`](https://developer.apple.com/documentation/swiftui/environmentobject). See <https://guide.elm-lang.org/architecture/> and <https://guide.elm-lang.org/webapps/structure.html> for more about this philosophy.
9
+
You can use Store as a single shared [`EnvironmentObject`](https://developer.apple.com/documentation/swiftui/environmentobject), or you can pass scoped parts of store down to sub-view through:
10
+
11
+
- Bare properties of `store.state`
12
+
- Ordinary SwiftUI bindings
13
+
- ViewStores that that offer a scoped view over an underlying shared parent store.
10
14
11
15
## Example
12
16
@@ -26,16 +30,18 @@ enum AppAction {
26
30
structAppEnvironment {
27
31
}
28
32
29
-
/// App state
30
-
structAppState: Equatable{
33
+
/// Conform your model to `ModelProtocol`.
34
+
/// A `ModelProtocol` is any `Equatable` that has a static update function
35
+
/// like the one below.
36
+
structAppModel: ModelProtocol {
31
37
var count =0
32
38
33
-
///State update function
39
+
///Update function
34
40
staticfuncupdate(
35
-
state: AppState,
41
+
state: AppModel,
36
42
action: AppAction,
37
43
environment: AppEnvironment
38
-
) -> Update<AppState, AppAction> {
44
+
) -> Update<AppModel> {
39
45
switch action {
40
46
case .increment:
41
47
var model = state
@@ -47,8 +53,7 @@ struct AppState: Equatable {
47
53
48
54
structAppView: View {
49
55
@StateObjectvar store =Store(
50
-
update: AppState.update,
51
-
state: AppState(),
56
+
state: AppModel(),
52
57
environment: AppEnvironment()
53
58
)
54
59
@@ -74,15 +79,97 @@ struct AppView: View {
74
79
75
80
A `Store` is a source of truth for application state. It's an [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), so you can use it anywhere in SwiftUI that you would use an ObservableObject—as an [@ObservedObject](https://developer.apple.com/documentation/swiftui/observedobject), a [@StateObject](https://developer.apple.com/documentation/swiftui/stateobject), or [@EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject).
76
81
77
-
Store exposes a single [`@Published`](https://developer.apple.com/documentation/combine/published) property, `state`, which represents your application state. `state`is read-only, and cannot be updated directly. Instead, like Elm or Redux, all `state` changes happen through a single `update` function, with the signature:
82
+
Store exposes a single [`@Published`](https://developer.apple.com/documentation/combine/published) property, `state`, which represents your application state. `state`can be any type that conforms to `ModelProtocol`.
`state` is read-only, and cannot be updated directly. Instead, all state changes are returned by an update function that you implement as part of `ModelProtocol`.
85
+
86
+
```swift
87
+
structAppModel: ModelProtocol {
88
+
var count =0
89
+
90
+
/// Update function
91
+
staticfuncupdate(
92
+
state: AppModel,
93
+
action: AppAction,
94
+
environment: AppEnvironment
95
+
) -> Update<AppModel> {
96
+
switch action {
97
+
case .increment:
98
+
var model = state
99
+
model.count= model.count+1
100
+
returnUpdate(state: model)
101
+
}
102
+
}
103
+
}
81
104
```
82
105
83
106
The `Update` returned is a small struct that contains a new state, plus any optional effects and animations associated with the state transition (more about that in a bit).
84
107
85
-
`state` can be any [`Equatable`](https://developer.apple.com/documentation/swift/equatable) type, typically a struct. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes. Additionally, because state is Equatable, you can make any view that relies on Store, or part of Store, an [EquatableView](https://developer.apple.com/documentation/swiftui/equatableview), so the view’s body will only be recalculated if the values it cares about change.
108
+
`ModelProtocol` inherits from `Equatable`. Before setting a new state, Store checks that it is not equal to the previous state. New states that are equal to old states are not set, making them a no-op. This means views only recalculate when the state actually changes.
109
+
110
+
## Effects
111
+
112
+
Updates are also able to produce asynchronous effects via [Combine](https://developer.apple.com/documentation/combine) publishers. This gives you a deterministic way to schedule sync and async side-effects like HTTP requests or database calls in response to actions.
113
+
114
+
Effects are modeled as [Combine Publishers](https://developer.apple.com/documentation/combine/publishers) which publish actions and never fail. For convenience, ObservableStore defines a typealias for effect publishers:
The most common way to produce effects is by exposing methods on `Environment` that produce effects publishers. For example, an asynchronous call to an authentication API service might be implemented in `Environment`, where an effects publisher is used to signal whether authentication was successful.
Store will manage the lifecycle of any publishers returned by an Update, piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.
151
+
152
+
## Animations
153
+
154
+
You can also drive explicit animations as part of an Update.
155
+
156
+
Use `Update.animation` to set an explicit [Animation](https://developer.apple.com/documentation/swiftui/animation) for this state update.
157
+
158
+
```swift
159
+
funcupdate(
160
+
state: Model,
161
+
action: Action,
162
+
environment: Environment
163
+
) -> Update<Model> {
164
+
switch action {
165
+
// ...
166
+
case .authenticate(let credentials):
167
+
returnUpdate(state: state).animation(.default)
168
+
}
169
+
}
170
+
```
171
+
172
+
When you specify a transition or animation as part of an Update, Store will use that animation when setting the state for the update.
86
173
87
174
## Getting and setting state in views
88
175
@@ -102,12 +189,15 @@ Button("Set color to red") {
102
189
}
103
190
```
104
191
105
-
`Store.binding(get:tag:)` lets you create a [binding](https://developer.apple.com/documentation/swiftui/binding) that represents some part of the state. A get function reads the state into a value, a tag function turns a value set on the binding into an action. The result is a binding that can be passed to any vanilla SwiftUI view, yet changes state only through deterministic updates.
192
+
## Bindings
193
+
194
+
`Binding(store:get:tag:)` lets you create a [binding](https://developer.apple.com/documentation/swiftui/binding) that represents some part of the store state. The `get` closure reads the state into a value, and the `tag` closure wraps the value set on the binding in an action. The result is a binding that can be passed to any vanilla SwiftUI view, but changes state only through deterministic updates.
106
195
107
196
```swift
108
197
TextField(
109
198
"Username"
110
-
text: store.binding(
199
+
text:Binding(
200
+
store: store,
111
201
get: { state in state.username },
112
202
tag: { username in .setUsername(username) }
113
203
)
@@ -119,92 +209,138 @@ Or, shorthand:
119
209
```swift
120
210
TextField(
121
211
"Username"
122
-
text: store.binding(
212
+
text:Binding(
213
+
store: store,
123
214
get: \.username,
124
215
tag: .setUsername
125
216
)
126
217
)
127
218
```
128
219
129
-
You can also create bindings for sub-properties, just like with any other SwiftUI binding. Here's an example of creating a binding to a deep property of the state:
130
-
131
-
```swift
132
-
TextField(
133
-
"Bio"
134
-
text: store
135
-
.binding(
136
-
get: { state in state.settings },
137
-
tag: { settings in .setSettings(settings) }
138
-
)
139
-
.profile
140
-
.bio
141
-
)
142
-
```
143
-
144
220
Bottom line, because Store is just an ordinary [ObservableObject](https://developer.apple.com/documentation/combine/observableobject), and can produce bindings, you can write views exactly the same way you write vanilla SwiftUI views. No special magic! Properties, [@Binding](https://developer.apple.com/documentation/swiftui/binding), [@ObservedObject](https://developer.apple.com/documentation/swiftui/observedobject), [@StateObject](https://developer.apple.com/documentation/swiftui/stateobject) and [@EnvironmentObject](https://developer.apple.com/documentation/swiftui/environmentobject) all work as you would expect.
145
221
146
-
## Effects
147
222
148
-
Updates are also able to produce asyncronous effects via [Combine](https://developer.apple.com/documentation/combine) publishers. This lets you schedule asyncronous things like HTTP requests or database calls in response to actions. Using effects, you can model everything via a deterministic sequence of actions, even asyncronous side-effects.
149
-
150
-
Effects are modeled as [Combine Publishers](https://developer.apple.com/documentation/combine/publishers) which publish actions and never fail.
223
+
## ViewStore
224
+
225
+
ViewStore lets you create component-scoped stores from a shared root store. This allows you to create apps from free-standing components that all have their own local state, actions, and update functions, but share the same underlying root store. You can think of ViewStore as like a binding, except that it exposes the same StoreProtocol API that Store does.
151
226
152
-
For convenience, ObservableStore defines a typealias for effect publishers:
227
+
Imagine we have a stand-alone child component that looks something like this:
The most common way to produce effects is by exposing methods on `Environment` that produce effects publishers. For example, an asyncronous call to an authentication API service might be implemented in `Environment`, where an effects publisher is used to signal whether authentication was successful.
268
+
Now we want to integrate this child component with a parent component. To do this, we can create a ViewStore from the parent's root store. We just need to specify a way to map from this child component's state and actions to the root store state and actions. This is where `CursorProtocol` comes in. It defines three things:
269
+
270
+
- A way to `get` a local state from the root state
271
+
- A way to `set` a local state on a root state
272
+
- A way to `tag` a local action so it becomes a root action
You can subscribe to an effects publisher by returning it as part of an Update:
298
+
...This gives us everything we need to map from a local scope to the global store. Now we can create a scoped ViewStore from the shared app store and pass it down to our ChildView.
Store will manage the lifecycle of any publishers returned by an Update, piping the actions they produce back into the store, producing new states, and cleaning them up when they complete.
315
+
ViewStores can also be created from other ViewStores, allowing for hierarchical nesting of components.
189
316
190
-
## Animations
317
+
Now we just need to integrate our child component's update function with the root update function. Cursors gives us a handy shortcut by synthesizing an `update` function that automatically maps child state and actions to parent state and actions.
191
318
192
-
You can also drive explicit animations as part of an Update.
319
+
```swift
320
+
enumAppAction {
321
+
casechild(ChildAction)
322
+
}
193
323
194
-
Use `Update.animation` to set an explicit [Animation](https://developer.apple.com/documentation/swiftui/animation) for this state update.
324
+
structAppModel: ModelProtocol {
325
+
var child =ChildModel()
195
326
196
-
```swift
197
-
funcupdate(
198
-
state: State,
199
-
action: Action,
200
-
environment: Environment
201
-
) -> Update<State, Action> {
202
-
switch action {
203
-
// ...
204
-
case .authenticate(let credentials):
205
-
returnUpdate(state: state).animation(.default)
327
+
staticfuncupdate(
328
+
state: AppModel,
329
+
action: AppAction,
330
+
environment: AppEnvironment
331
+
) -> Update<AppModel> {
332
+
switch {
333
+
case .child(let action):
334
+
return AppChildCursor.update(
335
+
state: state,
336
+
action: action,
337
+
environment: ()
338
+
)
339
+
}
206
340
}
207
341
}
208
342
```
209
343
210
-
When you specify a transition or animation as part of an Update thisway, Store will use it when setting the state for the update.
344
+
This tagging/update pattern also gives parent components an opportunity to intercept and handle child actions in special ways.
345
+
346
+
That's it! You can get state and send actions from the ViewStore, just like any other store, and it will translate local state changes and fx into app-level state changes and fx. Using ViewStore you can compose an app from multiple stand-alone components that each describe their own domain model and update logic.
0 commit comments