Coder Social home page Coder Social logo

ReSwift 7 Roadmap about reswift HOT 20 OPEN

DivineDominion avatar DivineDominion commented on May 24, 2024 5
ReSwift 7 Roadmap

from reswift.

Comments (20)

sjmueller avatar sjmueller commented on May 24, 2024 11

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

from reswift.

sjmueller avatar sjmueller commented on May 24, 2024 3

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 🙌

from reswift.

jjgp avatar jjgp commented on May 24, 2024 1

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

from reswift.

DivineDominion avatar DivineDominion commented on May 24, 2024

Starting to collect changes:

  • Change reducer signature to non-optional (Action, Substate) -> Substate: #457

from reswift.

DivineDominion avatar DivineDominion commented on May 24, 2024

@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

from reswift.

mjarvis avatar mjarvis commented on May 24, 2024

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

from reswift.

garrettm avatar garrettm commented on May 24, 2024

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

from reswift.

JacquesBackbase avatar JacquesBackbase commented on May 24, 2024

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.

from reswift.

dani-mp avatar dani-mp commented on May 24, 2024

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

from reswift.

mjarvis avatar mjarvis commented on May 24, 2024

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.

from reswift.

dani-mp avatar dani-mp commented on May 24, 2024

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.

from reswift.

DivineDominion avatar DivineDominion commented on May 24, 2024

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?

from reswift.

JacquesBackbase avatar JacquesBackbase commented on May 24, 2024

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

from reswift.

mhamann avatar mhamann commented on May 24, 2024

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

from reswift.

sjmueller avatar sjmueller commented on May 24, 2024

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.

from reswift.

mhamann avatar mhamann commented on May 24, 2024

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

from reswift.

jjgp avatar jjgp commented on May 24, 2024
  • 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.

from reswift.

jjgp avatar jjgp commented on May 24, 2024

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

from reswift.

DivineDominion avatar DivineDominion commented on May 24, 2024

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

from reswift.

Verdier avatar Verdier commented on May 24, 2024

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.

from reswift.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.