Skip to content
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

Open
DivineDominion opened this issue Feb 7, 2022 · 20 comments
Open

ReSwift 7 Roadmap #487

DivineDominion opened this issue Feb 7, 2022 · 20 comments

Comments

@DivineDominion
Copy link
Contributor

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 and SwiftUI 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!

@DivineDominion
Copy link
Contributor Author

Starting to collect changes:

@DivineDominion DivineDominion added this to the 7.0 milestone Feb 8, 2022
@sjmueller
Copy link

sjmueller commented Mar 9, 2022

Thanks for starting this thread @DivineDominion, looking forward to seeing a version of ReSwift that takes full advantage of Combine and async/await, while being built especially for SwiftUI.

I wanted to share some of the subscription infrastructure we've built on top of ReSwift in order to use it with SwiftUI:

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 SwiftUI, the notion of a global state where each component can choose the right portion of state and what to do with it.

If there is interest, I can share the code that makes this possible! Would love to see it baked into ReSwift 7 🙌

@DivineDominion
Copy link
Contributor Author

DivineDominion commented Mar 9, 2022

@sjmueller Of course there's interest! :)

Looks like your SwiftUI views subscribe to substates and cache and expose these via @StateObject properties. That looks pretty straight-forward.

I still have next to no SwiftUI experience, so I can't even compare to @mjarvis's swiftui branch, really, but maybe there's something of interest to you: https://github.com/ReSwift/ReSwift/tree/mjarvis/swiftui

@mjarvis
Copy link
Member

mjarvis commented Mar 9, 2022

@sjmueller Would love for you to share the code for this in #455

@garrettm
Copy link

garrettm commented Mar 9, 2022

@sjmueller I'm very interested to see what you have for this!

@sjmueller
Copy link

sjmueller commented Mar 10, 2022

Hi guys, here's the code -- it's fairly plug and play if you're using the latest ReSwift

One other useful part to mention:

  • support for animations when subscribed portion of state changes, the animation type can be passed in
  • it's compatible with onReceive too, so you're covered in more complex scenarios where adjacent state within the component needs to react:
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()
}

@JacquesBackbase
Copy link

What I would like to see is something like the TCA (The Composable Architecture) library does and that is to inject some kind of Environment object to the store, so that things that come from the outside world have a place to be mocked easily, ive done it in a project already myself in a way by making my main reducer like this

    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 Store subclass where i can pass in this Environment as part of my custom init, but im running into some issues with that because Store's init is required which makes subclassing the store to do this not so great, as i have to reimplement that init, but then some of the properties i need to initialise are private so i cant.

I use the Environment to inject things like what thread to run on for my middleware, so when it comes time to test, my middleware can run synchronously so the store/middleware doesnt outlive the test run and makes it easier to check things. Other things that come from the iOS framework that you cant mock easily if you use directly in your reducers like generating a UUID, instead have a function in your Environment that knows how to make a UUID and in testing you can change the function to give something more predictable. Obviously you can do it in other ways, but this makes it you dont have to implement anything yourself or even have to think about it if it was already inside the Store.

@dani-mp
Copy link
Contributor

dani-mp commented May 20, 2022

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

@mjarvis
Copy link
Member

mjarvis commented May 20, 2022

Passing these services in manually into middleware is how I handle this.
eg: Store(middleware: [fooMiddleware(dependency: bar)])

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.

@dani-mp
Copy link
Contributor

dani-mp commented May 20, 2022

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.

@DivineDominion
Copy link
Contributor Author

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?

@JacquesBackbase
Copy link

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

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.

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.

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.

@mhamann
Copy link

mhamann commented Jun 27, 2022

@sjmueller thanks for posting this code here around observability! Do you know if there's a good way to support the @dynamicMemberLookup feature so that you could dynamically introspect the properties of a particular struct?

For example, if I had a currentUser property in my store and I wanted to reference that as my @StateObject within a View, so I could do things like user.name or user.birthday, it seems like that requires some sort of dynamic lookup in order for the compiler to be happy.

@sjmueller
Copy link

sjmueller commented Jun 27, 2022

Hi @mhamann,

If I understand your request, accessing a struct's property in your state is completely built in to this code, without any need for @dynamicMemberLookup. Here is an example of how we have our state setup:

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 SwiftUI:

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.

@mhamann
Copy link

mhamann commented Jun 28, 2022

Thanks for these tips! I realized I needed to use .current before accessing the actual property I wanted, which was where I went wrong.

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

@obj-p
Copy link

obj-p commented Aug 11, 2022

  • I like the idea of no optional state in the reducers!
  • I also concur it's easy enough to pass the environment when creating a Middleware.
  • I think it would be nice to have the option to scope a store to a substore. I know this is doable in TCA and I've been experimenting in doing it in my toy project without a action back to the store like here (sorry I haven't documented it yet): https://github.com/jjgp/swift-roots/blob/4b62fd19d66798be5deb6bdfa79fa0ab37fbfe6a/Tests/RootsTests/StoreTests.swift#L164
  • I think a pattern like Epics from https://redux-observable.js.org/ would be a great way to incorporate Combine into the Middleware. I take a swing at that in my toy library such that the pattern looks like this (The effect is combined and passed to a middleware like thunks are):
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)
        }
    }
}
  • Regarding the Middleware improvements... Maybe the Middleware signature can be unnested one level so that it is (Store, Next) -> Dispatch instead of (Store) -> (Next) -> (Dispatch) like ReduxJS. I can't remember why it's the latter... I really prefer Middleware to TCA as it's an extension point of the Store itself. Things like asserting the dispatch is on the main queue can be in Middleware instead of the Store's dispatch method like TCA
  • Async/Await/Tasks can be incorporated into Thunks
  • I'm trying to figure out how to replicate Sagas (https://redux-saga.js.org/) with AsyncStreams. Not sure it's worth it though.

@obj-p
Copy link

obj-p commented Aug 11, 2022

Maybe Middleware could be implemented like Vapor's interface? (https://docs.vapor.codes/advanced/middleware/)

@obj-p
Copy link

obj-p commented Aug 15, 2022

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 @resultBuilder could be a declarative way to accomplish it. I'm currently attempting to implement something like the following structure. Curious to have more thoughts on it:

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))
            }
        }
    }
}

@DivineDominion
Copy link
Contributor Author

DivineDominion commented Sep 29, 2022

I opened a discussion for a breaking API change to make the Store non-open. (Mostly because I don't see why we have that, to be honest :)) -- Please chime in in the issue #492

@Verdier
Copy link

Verdier commented Feb 6, 2023

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 StoreSubscriptionBuilder that handle initial value.

There are many advantages not mentioned here, such as:

  • Only re-rendering parts of the UI impacted by state changes
  • Easily mockable in previews thanks to a generic ObservableValue<T> return type

Note that the old StoreSubscriber way can easily be added using this new approach to ensure compatibility. If someone needs it on top of official ReSwif now, it's easy to adapt the StoreSubscriptionBuilder on today ReSwift version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants