Shared state management with StateBinding #409
RabugenTom
started this conversation in
Ideas
Replies: 1 comment 1 reply
-
I've refactored the code, renamed things and it's now also handling purely computed sub-states without internal storage as well as optional sub-states or conditional setters. This new variant doesn't use operators and guarantees that sub-states values are up-to-date with the state, and are updating it coherently. The At call sites, it now looks like: struct Feature: Equatable {
var title: String = "Something"
var value: Int = 2
var isEnabled: Bool = false
}
struct AppState: Equatable {
var string: String = ""
var int: Int = 0
// Feature with storage
var _feature1: Feature = .init()
static let _feature1 = BoundState(\Self._feature1) { [
.init(\.string, \.title),
.init(\.int, \.value),
// `isEnabled` storage is handled internally by `self._feature1` storage
] }
// Optional Feature with storage
var _feature3: Feature? = nil
static let _feature3 = OptionalBoundState(\Self._feature3) { [
.init(\.string, \.title),
.init(\.int, \.value),
] }
var flag: Bool = false
// Computed Feature, without internal storage
static let _feature2 = ComputedState(Self.self, Feature.init) { [
.init(\.string, \.title),
.init(\.int, \.value),
.init(\.flag, \.isEnabled),
] }
// Optional Computed Feature, without internal storage
var _feature4: Feature? { flag ? .init() : nil }
static let _feature4 = OptionalComputedState(\Self._feature4) { [
.init(\.string, \.title),
.init(\.int, \.value),
.init(\.flag, \.isEnabled),
] }
// Feature with storage, only set `_feature5` internal storage or `self.string` if changed
var _feature5: Feature = .init()
static let _feature5 = BoundState(\Self._feature5, removeDuplicateStorage: ==) { [
.init(\.string, \.title, removeDuplicates: ==),
.init(\.int, \.value),
] }
// Computed Feature, only set `self.string` if changed
static let _feature6 = ComputedState(Self.self, Feature.init) { [
.init(\.string, \.title, removeDuplicates: ==),
.init(\.int, \.value),
] }
// Computed Feature, using explicit get/set for `title`, only update `self.string` if changed
static let _feature7 = ComputedState(Self.self, Feature.init) { [
.init(get: {
$1.title = $0.string
}, set: {
if $0.string != $1.title {
$0.string = $1.title
}
}),
.init(\.int, \.value),
] }
}
/// Public accessors, used to scope stores or in reducers.
extension AppState {
var feature1: Feature {
get { Self._feature1.get(self) }
set { Self._feature1.set(&self, newValue) }
}
var feature2: Feature {
get { Self._feature2.get(self) }
set { Self._feature2.set(&self, newValue) }
}
var feature3: Feature? {
get { Self._feature3.get(self) }
set { Self._feature3.set(&self, newValue) }
}
var feature4: Feature? {
get { Self._feature4.get(self) }
set { Self._feature4.set(&self, newValue) }
}
var feature5: Feature {
get { Self._feature5.get(self) }
set { Self._feature5.set(&self, newValue) }
}
var feature6: Feature {
get { Self._feature6.get(self) }
set { Self._feature6.set(&self, newValue) }
}
var feature7: Feature {
get { Self._feature7.get(self) }
set { Self._feature7.set(&self, newValue) }
}
} Here is the updated code: Expand
/// If `B<Source, Destination>` is
/// `BoundState<Source, Destination>` or
/// `OptionalBoundState<Source, Destination>` or
/// `ComputedState<Source, Destination>` or
/// `OptionalComputedState<Source, Destination>`,
/// one can generate public accessors for `Destination` like
///
/// ```
/// extension Source {
/// static var _destination: B<Source, Destination>(...)
///
/// var destination: Destination {
/// get { Self._destination.get(self) }
/// set { Self._destination.set(&self, newValue) }
/// }
/// }
/// ```
/// This code is always the same and can be code-generated if needed.
/// A struct that describes a directional binding between instances of `Source` and `Destination`. Its main
/// use is to describe a connection between a property of `Source` and a property of `Destination`
/// using `BoundState`, `OptionalBoundState`, `ComputedState` or `ComputedOptionalState`
public struct PropertyBinding<Source, Destination> {
let get: (Source, inout Destination) -> Void
let set: (inout Source, Destination) -> Void
/// Initialize a binding between a `Source` instance and a`Destination` instance.
/// - Parameters:
/// - get: A function applied when `Destination` is requested from `Source`. The `Destination`
/// instance can be updated at this point.
/// - set: A function applied when `Destination` is set in `Source`. The `Source` instance can be
/// updated at this point.
public init(
get: @escaping (Source, inout Destination) -> Void = { _, _ in },
set: @escaping (inout Source, Destination) -> Void = { _, _ in }
) {
self.get = get
self.set = set
}
/// Initialized a `PropertyBinding` using a couble of `KeyPath` describing a similar property in
/// `Source` and `Destination`.
/// - Parameters:
/// - sourceValue: A `KeyPath` to get and set `Value` in `Source`
/// - destinationValue: A `KeyPath` to get and set `Value` in `Destination`
/// - removeDuplicates: Used when the `Value` is set on `Source`. If this function is implemented
/// and returns `true`, no assignation will occur and `Source` will be kept untouched.
public init<Value>(
_ sourceValue: WritableKeyPath<Source, Value>,
_ destinationValue: WritableKeyPath<Destination, Value>,
removeDuplicates: ((Value, Value) -> Bool)? = nil
) {
self.get = { source, destination in
destination[keyPath: destinationValue] = source[keyPath: sourceValue]
}
self.set = { source, destination in
guard removeDuplicates?(source[keyPath: sourceValue], destination[keyPath: destinationValue]) != true
else { return }
source[keyPath: sourceValue] = destination[keyPath: destinationValue]
}
}
/// Initialized a `PropertyBinding` using a couble of `KeyPath` describing a similar property in
/// `Source` and `Destination`. This binding is unidirectional (readonly on Source )and the
/// `KeyPath<Source, Value` doesn't need to be writable.
/// - Parameters:
/// - sourceValue: A `KeyPath` to get `Value` in `Source`
/// - destinationValue: A `KeyPath` to get and set `Value` in `Destination`
public init<Value>(
readonly sourceValue: KeyPath<Source, Value>,
_ destinationValue: WritableKeyPath<Destination, Value>
) {
self.get = { source, destination in
destination[keyPath: destinationValue] = source[keyPath: sourceValue]
}
self.set = { _, _ in
}
}
}
/// Bind a state of `Destination` with a state of `Source` with storage of a `Destination` instance in
/// `Source`. This should be used when `Destination` has internal properties that are not bound with
/// properties from `Source`.
public struct BoundState<Source, Destination> {
let storage: WritableKeyPath<Source, Destination>
let properties: [PropertyBinding<Source, Destination>]
let removeDuplicateStorage: ((Destination, Destination) -> Bool)?
/// Initialize an `BoundState<Source, Destination>` between a parent state `Source`
/// and a child state `Destination`. A private storage for a `Source` is provided so unaffected
/// properties are preserved between accesses. In other words, `Destination` can have private fields and
/// only properties specified in `properties` are synchronized with `Source`.
/// - Parameters:
/// - storage: A writable (private) keyPath to an instance of `Destination`, used to store
/// `Destination`'s internal properties.
/// - properties: A function that returns an array of `PropertyBinding<Source, Destination>`
public init(
_ storage: WritableKeyPath<Source, Destination>,
removeDuplicateStorage: ((Destination, Destination) -> Bool)? = nil,
_ properties: () -> [PropertyBinding<Source, Destination>] = { [] }
) {
self.properties = properties()
self.removeDuplicateStorage = removeDuplicateStorage
self.storage = storage
}
/// Initialize an `BoundState<Source, Destination>` between a parent state `Source`
/// and a child state `Destination`. A private storage for a `Source` is provided so unaffected
/// properties are preserved between accesses. In other words, `Destination` can have private fields and
/// only properties specified in `properties` are synchronized with `Source`.
/// - Parameters:
/// - storage: A writable (private) keyPath to an instance of `Destination`, used to store
/// `Destination`'s internal properties.
/// - properties: A function that returns an array of `PropertyBinding<Source, Destination>`
public init(
_ storage: WritableKeyPath<Source, Destination>,
_ properties: () -> [PropertyBinding<Source, Destination>] = { [] }
) {
self.properties = properties()
self.removeDuplicateStorage = nil
self.storage = storage
}
// Retrieve the storage in `source`, update it and returns the result.
public func get(_ source: Source) -> Destination {
var stored = source[keyPath: storage]
properties.forEach { $0.get(source, &stored) }
return stored
}
// Set the storage in `source` and update `source`.
public func set(_ source: inout Source, _ newValue: Destination) {
if removeDuplicateStorage?(source[keyPath: storage], newValue) != true {
source[keyPath: storage] = newValue
}
properties.forEach { $0.set(&source, newValue) }
}
}
/// Bind a optional state of `Destination` with a state of `Source` with storage of a `Destination?` instance in
/// `Source`. This should be used when `Destination` is optional and has internal properties that are not bound with
/// properties from `Source`.
public struct OptionalBoundState<Source, Destination> {
let storage: WritableKeyPath<Source, Destination?>
let properties: [PropertyBinding<Source, Destination>]
let removeDuplicateStorage: ((Destination?, Destination?) -> Bool)?
/// Initialize an `OptionalBoundState<Source, Destination>` between a parent state `Source`
/// and a child state `Destination`. A private storage for an optional `Source` is provided so unaffected
/// properties are preserved between accesses. In other words, `Destination` can have private fields and
/// only properties specified in `properties` are synchronized with `Source`. If the child is set to nil,
/// source properties other than the storage property are kept untouched
/// - Parameters:
/// - storage: A writable (private) keyPath to an instance of `Destination?`, used to store
/// `Destination`'s internal properties. If this instance is nil, the computed property will be also nil.
/// - properties: A function that returns an array of `PropertyBinding<Source, Destination>`
public init(
_ storage: WritableKeyPath<Source, Destination?>,
removeDuplicateStorage: ((Destination?, Destination?) -> Bool)? = nil,
_ properties: () -> [PropertyBinding<Source, Destination>] = { [] }
) {
self.properties = properties()
self.removeDuplicateStorage = removeDuplicateStorage
self.storage = storage
}
/// Initialize an `OptionalBoundState<Source, Destination>` between a parent state `Source`
/// and a child state `Destination`. A private storage for an optional `Source` is provided so unaffected
/// properties are preserved between accesses. In other words, `Destination` can have private fields and
/// only properties specified in `properties` are synchronized with `Source`. If the child is set to nil,
/// source properties other than the storage property are kept untouched
/// - Parameters:
/// - storage: A writable (private) keyPath to an instance of `Destination?`, used to store
/// `Destination`'s internal properties. If this instance is nil, the computed property will be also nil.
/// - properties: A function that returns an array of `PropertyBinding<Source, Destination>`
public init(
_ storage: WritableKeyPath<Source, Destination?>,
_ properties: () -> [PropertyBinding<Source, Destination>] = { [] }
) {
self.properties = properties()
self.removeDuplicateStorage = nil
self.storage = storage
}
// Retrieve the storage in `source`, update it and returns the result.
public func get(_ source: Source) -> Destination? {
guard var stored = source[keyPath: storage] else { return nil }
properties.forEach { $0.get(source, &stored) }
return stored
}
// Set the storage in `source` and update `source`.
public func set(_ source: inout Source, _ newValue: Destination?) {
if removeDuplicateStorage?(source[keyPath: storage], newValue) != true {
source[keyPath: storage] = newValue
}
guard let newValue = newValue else { return }
properties.forEach { $0.set(&source, newValue) }
}
}
/// Bind a state of `Destination` with a state of `Source`. This should be used when `Destination` has not internal
/// properties and its whole state can be derived from `Source`.
public struct ComputedState<Source, Destination> {
let properties: [PropertyBinding<Source, Destination>]
let destination: () -> Destination
/// Initialize a `ComputedState<Source, Destination>`. These derived states are used when all the
/// properties of `Destination` can be individually stored in `Source`.
/// - Parameters:
/// - source: The `Source`'s or parent state type
/// - destination: A function that returns a default instance of `Destination` (the child state)
/// - properties: A function that returns an array of `PropertyBinding<Source, Destination>`
public init(
_ source: Source.Type,
_ destination: @escaping () -> Destination,
properties: () -> [PropertyBinding<Source, Destination>] = { [] }
) {
self.destination = destination
self.properties = properties()
}
// Retrieve the storage in `source`, update it and returns the result.
public func get(_ source: Source) -> Destination {
var destination = destination()
properties.forEach { $0.get(source, &destination) }
return destination
}
// Set the storage in `source` and update `source`.
public func set(_ source: inout Source, _ newValue: Destination) {
properties.forEach { $0.set(&source, newValue) }
}
}
/// Bind a optional state of `Destination` with a state of `Source`. This should be used when `Destination` is optional
/// and has not internal properties so its whole state can be derived from `Source`.
public struct OptionalComputedState<Source, Destination> {
let properties: [PropertyBinding<Source, Destination>]
let destination: (Source) -> Destination?
/// Initialize a `OptionalComputedState<Source, Destination>`. These derived states are used when all the
/// properties of `Destination` can be individually stored in `Source`. If the child is set to nil, the source properties
/// are kept untouched
/// - Parameters:
/// - destination: A function that returns a default instance of `Destination` (the child state) or nil
/// - properties: A function that returns an array of `PropertyBinding<Source, Destination>`
public init(
_ destination: @escaping (Source) -> Destination?,
properties: () -> [PropertyBinding<Source, Destination>] = { [] }
) {
self.destination = destination
self.properties = properties()
}
// Retrieve the storage in `source`, update it and returns the result.
public func get(_ source: Source) -> Destination? {
guard var destination = destination(source) else { return nil }
properties.forEach { $0.get(source, &destination) }
return destination
}
// Set the storage in `source` and update `source`.
public func set(_ source: inout Source, _ newValue: Destination?) {
guard let newValue = newValue else { return }
properties.forEach { $0.set(&source, newValue) }
}
} |
Beta Was this translation helpful? Give feedback.
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
Hello
It is frequent that some child state
FeatureState
of a parent stateAppState
needs to be synchronized with some parent's property when accessed. One solution is to instanciate on demand aFeatureState
inAppState.featureState
's getter. One also needs to make sure to re-injectFeatureState
's new values into theAppState
whenfeatureState
is set. Furthermore,FeatureState
can host many properties which are internal and need to be stored too.One way to achieve this is to host a private storage instance of
FeatureState
. When accessing the public instance ofFeatureState
, one retrieves the private instance, updates it according toappState
and returns the result. When theFeatureState
value comes back, one has to update the private instance, but also other fields we used to update the value inbound.Using this approach, we have to write almost the same thing twice by construction, and any error while doing so will lead to inconsistencies in the expected state of the app.
I'm proposing an helper type to improve the user experience. This type is purely additional, and I'm proposing it as a discussion in case it can be improved.
Edit
I completely refactored the code. See my comment below
Previous implementation, only kept for reference
This type is called
SynchronizedLink<Source, Destination>
, generic over theSource == AppState
andDestination == FeatureState
. It stores aWritableKeyPath<Source, Destination>
(the private storage) and various links betweenSource
andDestination
.Each
Link
links a state ofSource
with a state ofDestination
(usually one field in each type, by the mean ofKeyPath
).These links are stored in the form of couple of function
(A, inout B) -> Void
(the getter) and(inout A, B) -> Void
(the setter). They can be instantiated explicitly or usingWriteableKeyPath
. I've also defined an operator<->
between twoWritableKeyPath
such as\Source.value <-> \Destination.value
instantiates a link stating that both values should be kept in sync.As an example, one can use
SynchronizedLink
as:This is equivalent to:
I'm not too fond of introducing new operators, but this is the only approach which seems to work without having to specify the types in the
KeyPath
s, which I'd like to avoid. I've also tried to use@resultBuilder
, but it can't infer the types by itself. In any case, the operator can be dropped and theLink
s can be defined explicitly. I'm trying to have the "prior art & co" discussion with myself, but I'm not experienced enough to reach any conclusion, so if there is a more natural shape for this operator, please let me know!I've also tried to use property wrappers, but it ultimately fails as we need to access the enclosing instance of the wrapper from the wrapper instance itself, which is not possible.
In the current configuration, Swift manages to infer all the types it needs from the
storage
KeyPath
used inSynchronizedLink
initializer.Here is the code for
SyncronizedLink
:What do you think about this approach? Do you see some way to improve it?
Beta Was this translation helpful? Give feedback.
All reactions