Skip to content

Commit b773ae5

Browse files
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:)`
1 parent 546c7d0 commit b773ae5

4 files changed

Lines changed: 672 additions & 217 deletions

File tree

README.md

Lines changed: 206 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22

33
A simple Elm-like Store for SwiftUI, based on [ObservableObject](https://developer.apple.com/documentation/combine/observableobject).
44

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.
66

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.
88

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.
1014

1115
## Example
1216

@@ -26,16 +30,18 @@ enum AppAction {
2630
struct AppEnvironment {
2731
}
2832

29-
/// App state
30-
struct AppState: 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+
struct AppModel: ModelProtocol {
3137
var count = 0
3238

33-
/// State update function
39+
/// Update function
3440
static func update(
35-
state: AppState,
41+
state: AppModel,
3642
action: AppAction,
3743
environment: AppEnvironment
38-
) -> Update<AppState, AppAction> {
44+
) -> Update<AppModel> {
3945
switch action {
4046
case .increment:
4147
var model = state
@@ -47,8 +53,7 @@ struct AppState: Equatable {
4753

4854
struct AppView: View {
4955
@StateObject var store = Store(
50-
update: AppState.update,
51-
state: AppState(),
56+
state: AppModel(),
5257
environment: AppEnvironment()
5358
)
5459

@@ -74,15 +79,97 @@ struct AppView: View {
7479

7580
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).
7681

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`.
7883

79-
```
80-
(State, Action, Environment) -> Update<State, Action>
84+
`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+
struct AppModel: ModelProtocol {
88+
var count = 0
89+
90+
/// Update function
91+
static func update(
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+
return Update(state: model)
101+
}
102+
}
103+
}
81104
```
82105

83106
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).
84107

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:
115+
116+
```swift
117+
public typealias Fx<Action> = AnyPublisher<Action, Never>
118+
```
119+
120+
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.
121+
122+
```swift
123+
struct Environment {
124+
// ...
125+
func authenticate(credentials: Credentials) -> AnyPublisher<Action, Never> {
126+
// ...
127+
}
128+
}
129+
```
130+
131+
You can subscribe to an effects publisher by returning it as part of an Update:
132+
133+
```swift
134+
func update(
135+
state: Model,
136+
action: Action,
137+
environment: Environment
138+
) -> Update<Model> {
139+
switch action {
140+
// ...
141+
case .authenticate(let credentials):
142+
return Update(
143+
state: state,
144+
fx: environment.authenticate(credentials: credentials)
145+
)
146+
}
147+
}
148+
```
149+
150+
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+
func update(
160+
state: Model,
161+
action: Action,
162+
environment: Environment
163+
) -> Update<Model> {
164+
switch action {
165+
// ...
166+
case .authenticate(let credentials):
167+
return Update(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.
86173

87174
## Getting and setting state in views
88175

@@ -102,12 +189,15 @@ Button("Set color to red") {
102189
}
103190
```
104191

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.
106195

107196
```swift
108197
TextField(
109198
"Username"
110-
text: store.binding(
199+
text: Binding(
200+
store: store,
111201
get: { state in state.username },
112202
tag: { username in .setUsername(username) }
113203
)
@@ -119,92 +209,138 @@ Or, shorthand:
119209
```swift
120210
TextField(
121211
"Username"
122-
text: store.binding(
212+
text: Binding(
213+
store: store,
123214
get: \.username,
124215
tag: .setUsername
125216
)
126217
)
127218
```
128219

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-
144220
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.
145221

146-
## Effects
147222

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.
151226

152-
For convenience, ObservableStore defines a typealias for effect publishers:
227+
Imagine we have a stand-alone child component that looks something like this:
153228

154229
```swift
155-
public typealias Fx<Action> = AnyPublisher<Action, Never>
230+
enum ChildAction {
231+
case increment
232+
}
233+
234+
struct ChildModel: ModelProtocol {
235+
var count: Int = 0
236+
237+
static func update(
238+
state: ChildModel,
239+
action: ChildAction,
240+
environment: Void
241+
) -> Update<ChildModel> {
242+
switch action {
243+
case .increment:
244+
var model = state
245+
model.count = model.count + 1
246+
return Update(state: model)
247+
}
248+
}
249+
}
250+
251+
struct ChildView: View {
252+
var store: ViewStore<ChildModel>
253+
254+
var body: some View {
255+
VStack {
256+
Text("Count \(store.state.count)")
257+
Button(
258+
"Increment",
259+
action: {
260+
store.send(ChildAction.increment)
261+
}
262+
)
263+
}
264+
}
265+
}
156266
```
157267

158-
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
159273

160274
```swift
161-
struct Environment {
162-
// ...
163-
func authenticate(credentials: Credentials) -> AnyPublisher<Action, Never> {
164-
// ...
275+
struct AppChildCursor: CursorProtocol {
276+
/// Get child state from parent
277+
static func get(state: ParentModel) -> ChildModel {
278+
state.child
279+
}
280+
281+
/// Set child state on parent
282+
static func set(state: ParentModel, inner child: ChildModel) -> ParentModel {
283+
var model = state
284+
model.child = child
285+
return model
286+
}
287+
288+
/// Tag child action so it becomes a parent action
289+
static func tag(_ action: ChildAction) -> ParentAction {
290+
switch action {
291+
default:
292+
return .child(action)
293+
}
165294
}
166295
}
167296
```
168297

169-
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.
170299

171300
```swift
172-
func update(
173-
state: State,
174-
action: Action,
175-
environment: Environment
176-
) -> Update<State, Action> {
177-
switch action {
178-
// ...
179-
case .authenticate(let credentials):
180-
return Update(
181-
state: state,
182-
fx: environment.authenticate(credentials: credentials)
301+
struct ContentView: View {
302+
@ObservedObject private var store: Store<AppModel>
303+
304+
var body: some View {
305+
ChildView(
306+
store: ViewStore(
307+
store: store,
308+
cursor: AppChildCursor.self
309+
)
183310
)
184311
}
185312
}
186313
```
187314

188-
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.
189316

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.
191318

192-
You can also drive explicit animations as part of an Update.
319+
```swift
320+
enum AppAction {
321+
case child(ChildAction)
322+
}
193323

194-
Use `Update.animation` to set an explicit [Animation](https://developer.apple.com/documentation/swiftui/animation) for this state update.
324+
struct AppModel: ModelProtocol {
325+
var child = ChildModel()
195326

196-
```swift
197-
func update(
198-
state: State,
199-
action: Action,
200-
environment: Environment
201-
) -> Update<State, Action> {
202-
switch action {
203-
// ...
204-
case .authenticate(let credentials):
205-
return Update(state: state).animation(.default)
327+
static func update(
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+
}
206340
}
207341
}
208342
```
209343

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

Comments
 (0)