New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ReSwift 7 Roadmap #487
Comments
Starting to collect changes:
|
Thanks for starting this thread @DivineDominion, looking forward to seeing a version of ReSwift that takes full advantage of I wanted to share some of the subscription infrastructure we've built on top of struct ConversationList: View {
// Subscribe to a single part of state, and SwiftUI re-renders on changes
@StateObject var friends = store.subscribe { $0.account.friends }
// Subscribe and transform to the desired structure
@StateObject var friendRequests = store.subscribe(
select: { $0.account.friendRequests },
transform: { $0?.values.sorted(by: { $0.id > $1.id }) }
)
// Subscribe with combine throttling, useful when state is rapidly changing
@StateObject var conversations = store.subscribeThrottled(
select: { $0.conversations.results },
transform: { $0?.values.filter { $0.type.equals(any: .direct, .group) } }
)
var body: some View {
ZStack {
ScrollView {
LazyVStack {
ForEach(conversations.current) { conversation in
Row(conversation)
}
}
}
}
}
} This has worked really well for us in If there is interest, I can share the code that makes this possible! Would love to see it baked into ReSwift 7 🙌 |
@sjmueller Of course there's interest! :) Looks like your SwiftUI views subscribe to substates and cache and expose these via I still have next to no SwiftUI experience, so I can't even compare to @mjarvis's |
@sjmueller Would love for you to share the code for this in #455 |
@sjmueller I'm very interested to see what you have for this! |
Hi guys, here's the code -- it's fairly plug and play if you're using the latest One other useful part to mention:
ZStack {
}
.onReceive(entries.objectDidChange) { changeset in
if let last = changeset.new {
// do something with local @State
}
} We've used this code in production for awhile -- try it out below and lmk, happy to answer any questions! // Sam Mueller, Blink Labs
import ReSwift
import SwiftUI
import ReSwiftRouter
import Combine
class ObservableState<T: Hashable>: ObservableObject, StoreSubscriber, ObservableSubscription {
@Published fileprivate(set) var current: T
let selector: (AppState) -> T
fileprivate let animation: SwiftUI.Animation?
fileprivate var isSubscribed: Bool = false
fileprivate var cancellables = Set<AnyCancellable>()
// MARK: Lifecycle
public init(select selector: @escaping (AppState) -> (T), animation: SwiftUI.Animation? = nil) {
self.current = selector(store.state)
self.selector = selector
self.animation = animation
self.subscribe()
}
func subscribe() {
guard !isSubscribed else { return }
store.subscribe(self, transform: { [self] in $0.select(selector) })
isSubscribed = true
}
func unsubscribe() {
guard isSubscribed else { return }
store.unsubscribe(self)
isSubscribed = false
}
deinit {
unsubscribe()
}
public func newState(state: T) {
guard self.current != state else { return }
DispatchQueue.main.async {
let old = self.current
if let animation = self.animation {
withAnimation(animation) {
self.current = state
}
} else {
self.current = state
}
self.objectDidChange.send(DidChangeSubject(old: old, new: self.current))
}
}
public let objectDidChange = PassthroughSubject<DidChangeSubject<T>,Never>()
struct DidChangeSubject<T> {
let old: T
let new: T
}
}
class ObservableThrottledState<T: Hashable>: ObservableState<T> {
// MARK: Lifecycle
public init(select selector: @escaping (AppState) -> (T), animation: SwiftUI.Animation? = nil, throttleInMs: Int) {
super.init(select: selector, animation: animation)
objectThrottled
.throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] in self?.current = $0 }
.store(in: &cancellables)
}
override public func newState(state: T) {
guard self.current != state else { return }
DispatchQueue.main.async {
let old = self.current
if let animation = self.animation {
withAnimation(animation) {
self.objectThrottled.send(state)
}
} else {
self.objectThrottled.send(state)
}
self.objectDidChange.send(DidChangeSubject(old: old, new: self.current))
}
}
private let objectThrottled = PassthroughSubject<T, Never>()
}
class ObservableDerivedState<Original: Hashable, Derived: Hashable>: ObservableObject, StoreSubscriber, ObservableSubscription {
@Published public var current: Derived
let selector: (AppState) -> Original
let transform: (Original) -> Derived
fileprivate let animation: SwiftUI.Animation?
fileprivate var isSubscribed: Bool = false
fileprivate var cancellables = Set<AnyCancellable>()
// MARK: Lifecycle
public init(select selector: @escaping (AppState) -> Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil) {
self.current = transform(selector(store.state))
self.selector = selector
self.transform = transform
self.animation = animation
self.subscribe()
}
func subscribe() {
guard !isSubscribed else { return }
store.subscribe(self, transform: { [self] in $0.select(selector) })
isSubscribed = true
}
func unsubscribe() {
guard isSubscribed else { return }
store.unsubscribe(self)
isSubscribed = false
}
deinit {
unsubscribe()
}
public func newState(state original: Original) {
DispatchQueue.main.async {
let old = self.current
self.objectWillChange.send(ChangeSubject(old: old, new: self.current))
if let animation = self.animation {
withAnimation(animation) {
self.current = self.transform(original)
}
} else {
self.current = self.transform(original)
}
self.objectDidChange.send(ChangeSubject(old: old, new: self.current))
}
}
public let objectWillChange = PassthroughSubject<ChangeSubject<Derived>,Never>()
public let objectDidChange = PassthroughSubject<ChangeSubject<Derived>,Never>()
struct ChangeSubject<Derived> {
let old: Derived
let new: Derived
}
}
class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashable>: ObservableDerivedState<Original, Derived> {
// MARK: Lifecycle
public init(select selector: @escaping (AppState) -> Original, transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil, throttleInMs: Int) {
super.init(select: selector, transform: transform, animation: animation)
objectThrottled
.throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] in
self?.current = transform($0)
}
.store(in: &cancellables)
}
override public func newState(state original: Original) {
let old = current
if let animation = animation {
withAnimation(animation) {
objectThrottled.send(original)
}
} else {
objectThrottled.send(original)
}
DispatchQueue.main.async { self.objectDidChange.send(ChangeSubject(old: old, new: self.current)) }
}
private let objectThrottled = PassthroughSubject<Original, Never>()
}
extension Store where State == AppState {
func subscribe<T>(select selector: @escaping (AppState) -> (T), animation: SwiftUI.Animation? = nil) -> ObservableState<T> {
ObservableState(select: selector, animation: animation)
}
func subscribe<Original, Derived>(select selector: @escaping (AppState) -> (Original), transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil) -> ObservableDerivedState<Original, Derived> {
ObservableDerivedState(select: selector, transform: transform, animation: animation)
}
func subscribeThrottled<T>(select selector: @escaping (AppState) -> (T), throttleInMs: Int = 350, animation: SwiftUI.Animation? = nil) -> ObservableThrottledState<T> {
ObservableThrottledState(select: selector, animation: animation, throttleInMs: throttleInMs)
}
func subscribeThrottled<Original, Derived>(select selector: @escaping (AppState) -> (Original), transform: @escaping (Original) -> Derived, throttleInMs: Int = 350, animation: SwiftUI.Animation? = nil) -> ObservableDerivedThrottledState<Original, Derived> {
ObservableDerivedThrottledState(select: selector, transform: transform, animation: animation, throttleInMs: throttleInMs)
}
}
protocol ObservableSubscription {
func unsubscribe()
}
protocol Initializable {
init()
} |
What I would like to see is something like the TCA (The Composable Architecture) library does and that is to inject some kind of static func reducer(environment: Environment) -> Reducer<State> {
return { action, state in
guard let state = state else {
fatalError("Initial state should not be nil")
}
return State(
rdc: RDC.reducer(action: action, state: state.rdc, environment: environment)
)
}
} then when creating the store i go like this let environment = Environment()
let store = Store<State>(
reducer: reducer(environment: environment),
state: state,
middleware: middleware
) which is okish, but would be nice if the store would hold onto it and also give you access to it inside the middleware. I am passing the environment to my middleware in an equally awkward way at the moment. I've been trying to create my own I use the |
@JacquesBackbase why don't you put it as part of your state? You'll have access to it from everywhere in the app (including middleware) and it even gives you the ability to change part of it on the fly if needed. |
Passing these services in manually into middleware is how I handle this. One should not be passing this sort of thing into Reducers -- reducers should not have dependencies and potentially expensive operations -- they should be pure, operating only on contents of an action and the state. any object initialization should happen either pre-action dispatch, or in middleware. The action itself should be raw information, and the reducer should just apply that information to the state. In TCA, this is different because reducers are combined with middleware via the Effects setup. Therefore one needs a way to pass dependencies into reducers for use. |
I thought the environment was just plain values, of course. For services, I inject them into the middleware as well. For instance, I have a middleware that handles a music player instance. In my tests, I inject a mocked player into the middleware. |
Could we maybe make Middlewares easier for users to pick up? I'm Stockholm-syndrome-ified and know how to use Middlewares nowadays, but the language has changed since ReSwift 6, so maybe y'all have tried something useful that's more approachable than closures returning closures returning closures? Maybe this is ""just"" a documentation issue where we could improve (a) the onboarding docs to show how to inject dependencies, or (b) the inline code docs that Xcode can now show in the help viewer, to list examples and best practices like this? |
I like to keep my state equatable, and the things you put in the environment might not necessarily be something that can be equatable, like a closure or something. also they would never change so its not something that could accidentally be changed in a reducer.
Agreed, and this is not what its for, think of it more like passing implementation details that shouldnt really be exposed outside in say your view where this would have to be generated and passed in as a payload of an action. it also makes a convenient place for mocking these details and can be reused between tests easily. Ideally the environment just stores a bunch of values or a function that just can just generate a value, they should not be something complex. The environment also never changes and is passed in as a parameter which means the function is still pure. To put something that has side effects in the environment would be as much of a mistake as putting side effect code into your reducer. I just thought the way the TCA library was doing things seemed reasonable, so i took a page out of their book, but maybe im grossly misusing it. But you are right as well, this does kind of open the door for abuse if it is misused, does make it easier to make a mistake by accidentally introducing a side effect unintentionally. |
@sjmueller thanks for posting this code here around observability! Do you know if there's a good way to support the For example, if I had a |
Hi @mhamann, If I understand your request, accessing a struct AppState {
var account = AccountState()
}
struct AccountState {
var user: Model.User? = nil
}
struct Model {
struct User: Identifiable, Hashable, Codable {
var id: Int
var name: String
var username: String
var avatar: URL?
}
} And it's completely typesafe when we want to observe the user properties in struct Profile: View {
@StateObject private var profile = store.subscribe { $0.account.user }
var body: some View {
VStack {
if let profile = profile.current {
Avatar(url: profile.avatar, size: 150, onPress: openMenu)
Text(profile.name)
Text("@\(profile.username)")
}
}
}
} On the other hand, if you want to access those properties in a non-typesafe manner via dynamic string keys (similar to javascript), we haven't really experimented with anything like that. |
Thanks for these tips! I realized I needed to use Your implementation has been incredibly useful in solving a problem I was having, so thank you again! And yes, I do plan to plug this state into React Native/Javascript in the near future, so that will indeed be interesting... 😬 |
extension ContextEffect where State == FeedState, Action == FeedAction, Context == FeedContext {
static func fetchListing() -> Self {
ContextEffect { states, actions, context in
states
.zip(actions)
.compactMap { state, action -> HTTPRequest<RedditModel.Listing>? in
if case .fetchListing = action {
let after = state.listings.last?.after
return RedditRequest.listing(after: after)
} else {
return nil
}
}
.map { request in
context.http.requestPublisher(for: request)
}
.switchToLatest()
.map { listing in
FeedAction.pushListing(listing)
}
.catch { _ in
Just(FeedAction.fetchListingErrored)
}
.receive(on: context.mainQueue)
}
}
}
|
Maybe Middleware could be implemented like Vapor's interface? (https://docs.vapor.codes/advanced/middleware/) |
Another note on making Middleware/enhancements easier to use on using new langauge features, I've been trying to replicate Sagas (https://redux-saga.js.org/) and have come to thought that final class FetchFeedPage: Saga<FeedState, FeedAction> {
@EffectBuilder open var run: Effect<State, Action> {
Take { action in
action.type == "fetchSomething"
}
Select { state in
let nextPage = state.feed.nextPage
Call {
let items = await api.fetch(page: nextPage)
Put(AppendFeedPage(items: items))
}
}
}
} |
I opened a discussion for a breaking API change to make the |
Hi! I've created a SwiftUI-compatible version of ReSwift and it works really well. I'd like to share it with you as inspiration: https://gist.github.com/Verdier/746cb771f8ce4146d14c519934a51a2c. It's initially inspired by @sjmueller Here are some examples: @StateObject var myValue = store.subscribe()
.map { state in state.myValue }
.toObservableObject(animation: .interactiveSpring()) // optional animation
@StateObject var myDerivedValue = store.subscribe()
.map { state in state.myValue }
.removeDuplicates()
.map { value in complexTransformation(value) } // triggered only on new value
.toObservableObject()
@StateObject var myValue = store.subscribe()
.map { state in state.myValue }
.throttle(millis: 500)
.toObservableObject(animation: .interactiveSpring()) // optional animation
// MARK: Helpers
/* Shorcut for map with removeDuplicates if myValue is Equatable */
@StateObject var myValue = store.select { state in state.myValue } /* , animation: */
/* Shorcut for map -> removeDuplicates if myValue is Equatable -> map */
@StateObject var myDerivedValue = store.select(
{ state in state.myValue },
transform: { value in complexTransformation(value) }
/* , animation: ... */
) The store subscription mechanism has been replaced by a Combine Publisher and a flexible and extendable There are many advantages not mentioned here, such as:
Note that the old |
Hey folks!
The past couple years have brought numerous changes to Swift that would have no doubt impacted ReSwift if they were available back then.
Combine
andSwiftUI
and async/await are some of the most prominent language features.I wanted to open a place for discussion, wishful thinking, and planning of a version 7 release of ReSwift.
I know that @mjarvis has experimented with a SwiftUI-first variant of ReSwift. And @mjarvis and yours truly have tried to change the subscription code to enable improvements to subscriber management. Introducing such breaking changes incrementally into the v6.x timeline doesn't seem to fit well.
But for v7, we can dream up breaking changes and maybe begin dropping support for 6 y.o. operating systems if they truly hold ReSwift back.
Looking forward to the contributions!
The text was updated successfully, but these errors were encountered: