Skip to content

Commit 332458e

Browse files
Introduce KeyedCursorProtocol, remove ViewStore in favor of forward (#19)
Fixes #18. This PR sketches out one potential solution to #18. It refactors our approach to sub-components by decomplecting action sending from state getting. - Removes `ViewStore` - Introduces `Address.forward(send:tag:)` which gives us an easy way to create tagged `send` functions. This solves one part of what ViewStore was solving. - Introduces `Binding(get:send:tag:)` which gives us the binding equivalent to `Address.forward` - Introduces `KeyedCursorProtocol` which offers an alternative cursor for subcomponents that need to be looked up within dynamic lists. This refactor is in response to the awkwardness of the `ViewStore/Cursor` paradigm for components that are part of a dynamic list. Even if we had created a keyed cursor initializer for ViewStore, it necessarily would have had to hold an optional (nillable) state. This is because ViewStore lookup was dynamic, and this trips up the lifetime typechecking around the model. In practice, a view would not exist if its model did not exist, but this is not a typesafe guarantee for dynamic list lookups. Anyway, the whole paradigm of looking up child from parent dynamically is a bit odd for list items. In SwiftUI the typical approach is to ForEach, and then pass the model data down as a static property to the view. This guarantees type safety, since a view holds its own copy of the data. What if we could do something more like that? The approach in this PR leans into this approach. State can be passed to sub-components as plain old properties. `Address.forward` can be used to create view-local send functions that you can pass down to sub-views. `Binding` gets a similar form. In both cases, we can use a closure to capture additional parent-scoped state, such as an ID for lookup within the parent model. Cursor sticks around, but mostly as a convenient way to create update functions for sub-components. We also introduce `KeyedCursorProtocol` which offers a keyed equivalent for dynamic lookup. ## Usage Sub-components become more "vanilla", just using bare properties and closures. ```swift struct ParentView: View { @StateObject = Store( AppModel(), AppEnvironment() ) var body: some View { ChildModel( state: store.state.child, send: Address.forward( send: store.send, tag: ParentChildCursor.tag ) ) } } struct ChildView: View { var state: ChildModel var send: (ChildAction) -> Void var body: some View { Button(state.text) { send(.activate) } } } ``` ## Prior art This approach is inspired by Reflex: - Forward https://github.com/mozilla/reflex/blob/c5e75e98bc601e2315b6d43e5e347263cf67359e/src/signal.js#L5 - Cursor https://github.com/browserhtml/browserhtml/blob/master/src/Common/Cursor.js
1 parent e54ae1d commit 332458e

5 files changed

Lines changed: 350 additions & 221 deletions

File tree

README.md

Lines changed: 37 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -191,40 +191,27 @@ Button("Set color to red") {
191191

192192
## Bindings
193193

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.
194+
`Binding(get:send: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.
195195

196196
```swift
197197
TextField(
198198
"Username"
199199
text: Binding(
200-
store: store,
201-
get: { state in state.username },
200+
get: { store.state.username },
201+
send: store.send,
202202
tag: { username in .setUsername(username) }
203203
)
204204
)
205205
```
206206

207-
Or, shorthand:
208-
209-
```swift
210-
TextField(
211-
"Username"
212-
text: Binding(
213-
store: store,
214-
get: \.username,
215-
tag: .setUsername
216-
)
217-
)
218-
```
219-
220207
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.
221208

222209

223-
## ViewStore
210+
## Scoping store for child components
224211

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.
212+
We can also create component-scoped state and send callbacks 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.
226213

227-
Imagine we have a stand-alone child component that looks something like this:
214+
Imagine we have a vanilla SWiftUI child view that looks something like this:
228215

229216
```swift
230217
enum ChildAction {
@@ -249,11 +236,12 @@ struct ChildModel: ModelProtocol {
249236
}
250237

251238
struct ChildView: View {
252-
var store: ViewStore<ChildModel>
239+
var state: ChildModel
240+
var send: (ChildAction) -> Void
253241

254242
var body: some View {
255243
VStack {
256-
Text("Count \(store.state.count)")
244+
Text("Count \(state.count)")
257245
Button(
258246
"Increment",
259247
action: {
@@ -265,12 +253,39 @@ struct ChildView: View {
265253
}
266254
```
267255

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:
256+
Now we want to integrate this child component with a parent component. To do this, we just need to specify a way to map from this child component's state and actions to the root store state and actions. First, we pass down the part of the state the child uses. Then we create a scoped `send` function using `Address.forward`. It maps child actions to parent actions using a `tag` closure we provide.
257+
258+
```swift
259+
struct ContentView: View {
260+
@StateObject private var store: Store<AppModel>
261+
262+
var body: some View {
263+
ChildView(
264+
state: store.state.child,
265+
send: Address.forward(
266+
send: store.send,
267+
tag: {
268+
switch action {
269+
default:
270+
return .child(action)
271+
}
272+
}
273+
)
274+
)
275+
}
276+
}
277+
```
278+
279+
Now we just need to integrate our child component's update function with the root update function. This is where `CursorProtocol` comes in.
280+
281+
It defines three things:
269282

270283
- A way to `get` a local state from the root state
271284
- A way to `set` a local state on a root state
272285
- A way to `tag` a local action so it becomes a root action
273286

287+
...and synthesizes an `update` function that automatically maps child state and actions to parent state and actions.
288+
274289
```swift
275290
struct AppChildCursor: CursorProtocol {
276291
/// Get child state from parent
@@ -295,27 +310,6 @@ struct AppChildCursor: CursorProtocol {
295310
}
296311
```
297312

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.
299-
300-
```swift
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-
)
310-
)
311-
}
312-
}
313-
```
314-
315-
ViewStores can also be created from other ViewStores, allowing for hierarchical nesting of components.
316-
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.
318-
319313
```swift
320314
enum AppAction {
321315
case child(ChildAction)
@@ -342,5 +336,3 @@ struct AppModel: ModelProtocol {
342336
```
343337

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

Sources/ObservableStore/ObservableStore.swift

Lines changed: 89 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,18 @@ where Model: ModelProtocol
258258
}
259259
}
260260

261+
public struct Address {
262+
/// Forward transform an address (send function) into a local address.
263+
/// View-scoped actions are tagged using `tag` before being forwarded to
264+
/// `send.`
265+
public static func forward<Action, ViewAction>(
266+
send: @escaping (Action) -> Void,
267+
tag: @escaping (ViewAction) -> Action
268+
) -> (ViewAction) -> Void {
269+
{ viewAction in send(tag(viewAction)) }
270+
}
271+
}
272+
261273
/// A cursor provides a complete description of how to map from one component
262274
/// domain to another.
263275
public protocol CursorProtocol {
@@ -302,84 +314,99 @@ extension CursorProtocol {
302314
}
303315
}
304316

305-
/// ViewStore is a local projection of a Store that can be passed down to
306-
/// a child view.
307-
// NOTE: ViewStore works like Binding. It reads state at runtime using a
308-
// getter closure that you provide. It is important that we
309-
// read the state via a closure, like Binding does, rather than
310-
// storing the literal value as a property of the instance.
311-
// If you store the literal value as a property, you will have "liveness"
312-
// issues with the data in views, especially around things like text editors.
313-
// Letters entered out of order, old states showing up, etc.
314-
// I suspect this has something to do with either the guts of SwiftUI or the
315-
// guts of UIViewRepresentable.
316-
// 2022-06-12 Gordon Brander
317-
public struct ViewStore<ViewModel: ModelProtocol>: StoreProtocol {
318-
private let _get: () -> ViewModel
319-
private let _send: (ViewModel.Action) -> Void
317+
public protocol KeyedCursorProtocol {
318+
associatedtype Key
319+
associatedtype Model: ModelProtocol
320+
associatedtype ViewModel: ModelProtocol
320321

321-
/// Initialize a ViewStore using a get and send closure.
322-
public init(
323-
get: @escaping () -> ViewModel,
324-
send: @escaping (ViewModel.Action) -> Void
325-
) {
326-
self._get = get
327-
self._send = send
328-
}
322+
/// Get an inner state from an outer state
323+
static func get(state: Model, key: Key) -> ViewModel?
329324

330-
/// Get current state
331-
public var state: ViewModel { self._get() }
325+
/// Set an inner state on an outer state, returning an outer state
326+
static func set(state: Model, inner: ViewModel, key: Key) -> Model
332327

333-
/// Send an action
334-
public func send(_ action: ViewModel.Action) {
335-
self._send(action)
336-
}
328+
/// Tag an inner action, transforming it into an outer action
329+
static func tag(action: ViewModel.Action, key: Key) -> Model.Action
337330
}
338331

339-
extension ViewStore {
340-
/// Initialize a ViewStore from a store of some type, and a cursor.
341-
/// - Store can be any type conforming to `StoreProtocol`
342-
/// - Cursor can be any type conforming to `CursorProtocol`
343-
public init<Store, Cursor>(store: Store, cursor: Cursor.Type)
344-
where
345-
Store: StoreProtocol,
346-
Cursor: CursorProtocol,
347-
Store.Model == Cursor.Model,
348-
ViewModel == Cursor.ViewModel
349-
{
350-
self.init(
351-
get: { Cursor.get(state: store.state) },
352-
send: { action in store.send(Cursor.tag(action)) }
332+
extension KeyedCursorProtocol {
333+
/// Update an inner state within an outer state through a keyed cursor.
334+
/// This cursor type is useful when looking up children in dynamic lists
335+
/// such as arrays or dictionaries.
336+
///
337+
/// - `state` the outer state
338+
/// - `action` the inner action
339+
/// - `environment` the environment for the update function
340+
/// - `key` a key uniquely representing this model in the parent domain
341+
/// - Returns an update for a new outer state or nil
342+
public static func update(
343+
state: Model,
344+
action viewAction: ViewModel.Action,
345+
environment viewEnvironment: ViewModel.Environment,
346+
key: Key
347+
) -> Update<Model>? {
348+
guard let viewModel = get(state: state, key: key) else {
349+
return nil
350+
}
351+
let next = ViewModel.update(
352+
state: viewModel,
353+
action: viewAction,
354+
environment: viewEnvironment
355+
)
356+
return Update(
357+
state: set(state: state, inner: next.state, key: key),
358+
fx: next.fx
359+
.map({ viewAction in Self.tag(action: viewAction, key: key) })
360+
.eraseToAnyPublisher(),
361+
transaction: next.transaction
353362
)
354363
}
355-
}
356364

357-
extension ViewStore {
358-
/// Create a ViewStore for a constant state that swallows actions.
359-
/// Convenience for view previews.
360-
public static func constant(
361-
state: ViewModel
362-
) -> ViewStore<ViewModel> {
363-
ViewStore<ViewModel>(
364-
get: { state },
365-
send: { action in }
366-
)
365+
/// Update an inner state within an outer state through a keyed cursor.
366+
/// This cursor type is useful when looking up children in dynamic lists
367+
/// such as arrays or dictionaries.
368+
///
369+
/// This version of update always returns an `Update`. If the child model
370+
/// cannot be found at key, then it returns an update for the same state
371+
/// (noop), effectively ignoring the action.
372+
///
373+
/// - `state` the outer state
374+
/// - `action` the inner action
375+
/// - `environment` the environment for the update function
376+
/// - `key` a key uniquely representing this model in the parent domain
377+
/// - Returns an update for a new outer state or nil
378+
public static func update(
379+
state: Model,
380+
action viewAction: ViewModel.Action,
381+
environment viewEnvironment: ViewModel.Environment,
382+
key: Key
383+
) -> Update<Model> {
384+
guard let next = update(
385+
state: state,
386+
action: viewAction,
387+
environment: viewEnvironment,
388+
key: key
389+
) else {
390+
return Update(state: state)
391+
}
392+
return next
367393
}
368394
}
369395

370396
extension Binding {
371397
/// Initialize a Binding from a store.
372-
/// - `get` reads the store state to a binding value.
373-
/// - `tag` transforms the value into an action.
398+
/// - `get` reads the binding value.
399+
/// - `send` sends actions to some address.
400+
/// - `tag` tags the value, turning it into an action for `send`
374401
/// - Returns a binding suitable for use in a vanilla SwiftUI view.
375-
public init<Store: StoreProtocol>(
376-
store: Store,
377-
get: @escaping (Store.Model) -> Value,
378-
tag: @escaping (Value) -> Store.Model.Action
402+
public init<Action>(
403+
get: @escaping () -> Value,
404+
send: @escaping (Action) -> Void,
405+
tag: @escaping (Value) -> Action
379406
) {
380407
self.init(
381-
get: { get(store.state) },
382-
set: { value in store.send(tag(value)) }
408+
get: get,
409+
set: { value in send(tag(value)) }
383410
)
384411
}
385412
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// BindingTests.swift
3+
//
4+
//
5+
// Created by Gordon Brander on 9/21/22.
6+
//
7+
8+
import XCTest
9+
import SwiftUI
10+
@testable import ObservableStore
11+
12+
final class BindingTests: XCTestCase {
13+
enum Action: Hashable {
14+
case setText(String)
15+
}
16+
17+
struct Model: ModelProtocol {
18+
var text = ""
19+
var edits: Int = 0
20+
21+
static func update(
22+
state: Model,
23+
action: Action,
24+
environment: Void
25+
) -> Update<Model> {
26+
switch action {
27+
case .setText(let text):
28+
var model = state
29+
model.text = text
30+
model.edits = model.edits + 1
31+
return Update(state: model)
32+
}
33+
}
34+
}
35+
36+
struct SimpleView: View {
37+
@Binding var text: String
38+
39+
var body: some View {
40+
Text(text)
41+
}
42+
}
43+
44+
/// Test creating binding for an address
45+
func testBinding() throws {
46+
let store = Store(
47+
state: Model(),
48+
environment: ()
49+
)
50+
51+
let binding = Binding(
52+
get: { store.state.text },
53+
send: store.send,
54+
tag: Action.setText
55+
)
56+
57+
let view = SimpleView(text: binding)
58+
59+
view.text = "Foo"
60+
view.text = "Bar"
61+
62+
XCTAssertEqual(
63+
store.state.text,
64+
"Bar"
65+
)
66+
XCTAssertEqual(
67+
store.state.edits,
68+
2
69+
)
70+
}
71+
}

0 commit comments

Comments
 (0)