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

Implemented redux-thunk-like action creator #240

Closed
wants to merge 3 commits into from

Conversation

mpsnp
Copy link

@mpsnp mpsnp commented Apr 27, 2017

This implementation allows us to write action creators with return value, just as redux-thunk middleware do. For example it allows composition of action creators this way:

protocol Sauce {
}

protocol SandwichServiceType {
    func getSecretSauce() -> Promise<Sauce>
}

struct MakeSandwich: Action {
    let person: String
    let sauce: Sauce
}

struct Apologize: Action {
    let fromPerson: String
    let toPerson: String
    let error: Error
}

class SandwichActor {
    let service: SandwichServiceType
    init(service: SandwichServiceType) {
        self.service = service
    }
    
    func makeASandwichWithSecretSauce(for person: String) -> ActionCreator<Promise<Void>> {
        return { (store) in
            return self.service.getSecretSauce().then { (sauce) in
                store.dispatch(MakeSandwich(person: person, sauce: sauce))
            }.recover { (error) -> Void in
                store.dispatch(Apologize(fromPerson: "Sandwich store", toPerson: person, error: error))
                throw error
            }
        }
    }
    
    func makeSandwichesForEveryone() -> ActionCreator<Promise<Void>> {
        return { (store) in
            return firstly {
                store.dispatch(self.makeASandwichWithSecretSauce(for: "Grandma"))
            }.then {
                when(fulfilled:
                    store.dispatch(self.makeASandwichWithSecretSauce(for: "Me")),
                    store.dispatch(self.makeASandwichWithSecretSauce(for: "My wife"))
                )
            }.then {
                store.dispatch(self.makeASandwichWithSecretSauce(for: "Kids"))
            }
        }
    }
}

// And finaly use this:

let sandwichActor = SandwichActor(service: realService)

store.dispatch(sandwichActor.makeSandwichesForEveryone())

What do you think about this?

if let action = action {
self.dispatch(action)
callback?(self.state)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)


open func dispatch(_ asyncActionCreatorProvider: @escaping AsyncActionCreator) {
dispatch(asyncActionCreatorProvider, callback: nil)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

func dispatch<ReturnValue>(_ actionCreator: ActionCreator<ReturnValue>) -> ReturnValue

@discardableResult
func dispatch<State: StateType, ReturnValue>(_ actionCreator: StatedActionCreator<State, ReturnValue>) -> ReturnValue

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line Length Violation: Line should be 120 characters or less: currently 121 characters (line_length)

@@ -1,5 +1,8 @@
import Foundation

public typealias ActionCreator<T> = (_ store: DispatchingStoreType) -> T
public typealias StatedActionCreator<S: StateType, T> = (_ store: DispatchingStoreType, _ getState: @escaping () -> S) -> T

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line Length Violation: Line should be 120 characters or less: currently 123 characters (line_length)


@discardableResult
func dispatch<ReturnValue>(_ actionCreator: ActionCreator<ReturnValue>) -> ReturnValue

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

@@ -21,4 +24,10 @@ public protocol DispatchingStoreType {
return type, e.g. to return promises
*/
func dispatch(_ action: Action)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

@dani-mp
Copy link
Contributor

dani-mp commented Apr 27, 2017

Hi! I love this, it feels closer to what the original Redux provides (even though the implementation is different because the differences between JavaScript and Swift, in Redux this is done in a separate middleware and not built-in in the core library).

I have a question: what's the purpose of having two different action creator type(alias) (ActionCreator and StatedActionCreator)? Maybe I'm missing something, but I think is more clear to have just the one that can both dispatch and query the state, but with the other name (ActionCreator). If the user doesn't need to access the state, only dispatch, they can just omit the parameter in the closure, right?

Other than that, 💯. Maybe the library maintainers have more to say about the code itself.

@mpsnp
Copy link
Author

mpsnp commented Apr 27, 2017

@danielmartinprieto, originally, the only purpose of StatedActionCreator is to provide getState() -> State function which returns already casted state. Because store is DispatchingStoreType, which doesn't have state.

@dani-mp
Copy link
Contributor

dani-mp commented Apr 27, 2017

Yup, that StatedActionCreator is the one that I'd keep (changing its name to ActionCreator), and I'd get rid of the one that only knows how to dispatch, for simplicity. My question was if there are two different versions for any purpose in particular.

@mpsnp
Copy link
Author

mpsnp commented Apr 27, 2017

There is only one problem (in terms of type safety): this implementation allows us to dispatch any StatedActionCreator, no matter if action creator's State matches stores one.

@danielmartinprieto Regarding two typealiases: as Dan Abramov wrote somewhere, accessing state in action creator is something like anti-pattern, thus, for those who want StatedActionCreator makes sense. But generally yep, leaving only one of them will make it more simple.

Also i'll reference #214 here, because this PR provides another way of handling this.

@mpsnp
Copy link
Author

mpsnp commented Apr 27, 2017

Also I didn't touch tests and didn't wrote comments yet, cause if this modification of such core functionality as ActionCreators doesn't fit library maintainers 'view' it will be just a waste of time 😄 But if you like it guys, i'll be happy to finish this PR till merge.

@dani-mp
Copy link
Contributor

dani-mp commented Apr 27, 2017

Yeah, true, I've read that as well, although everyone does it... apparently it's a better approach to access the state inside a middleware, instead.

Anyway, I think having this action creator is nice, I didn't see the problem where the two state types (the action creator's one and the store's one) don't match, maybe that can be improved.

Thanks for taking the time of implementing this, and answer my questions!

@Ben-G
Copy link
Member

Ben-G commented Apr 29, 2017

Hey @mpsnp, thanks a lot for taking the time to implement this!

I'm definitely interested in refining the async action creator API and this looks like a good starting point for a discussion!

I have a few raw thoughts about this PR. Sorry if they aren't expressed super well, but I wanted to get the first version out quickly instead of waiting a week to refine my comment 😉


Current state in Redux:

Redux states the following interface for action creators:

type ActionCreator = (...args: any) => Action | AsyncAction

So async and sync action creators are dispatched the same way. For action creators returning sync actions the store dispatches the created action immediately, for ones returning async actions a transform happens in the middleware that eventually leads to dispatch of an action.

Current state in ReSwift:

ReSwift currently provides two separate dispatch interfaces: one for action creators and another for async action creators. Both have a strict interface that allows the action creators to create exactly one action, either synchronously or asynchronously.

Suggested Change:

Your suggested change replaces the distinct dispatch functions with a single more flexible one.

Pros/Cons of change in my View:

Pros:

  • New API is more flexible and therefore allows composition of action creators, as you've nicely shown above!
  • (Closer to Redux. Though I don't think that's necessary a pro by itself. This library intends to take core ideas from Redux but not necessarily to provide the same API as there might be platform/language specific tradeoffs).

Cons:

  • I think API discoverability suffers with this change. Currently it is fairly obvious what the various dispatch methods are for sync/async from the method signatures. Unifying async/sync dispatch behind one method makes it more flexible, but also puts burden on the user to learn about both separate ways to use action creators.
  • No built-in async + callback support anymore. You demonstrate how redux-thunk can be used with e.g. promises in order to allow a user to dispatch async and get a response once async state update completed. This sort of modularity is core to Redux (and the JS community in general). However, for ReSwift I would like to provide a baseline of tools out of the box. So I would be in favor of keeping some API that doesn't require extensions to support async + callback.

TL;DR
The power of the more flexible API is awesome! However, the loss of discoverability and the "out of the box" solution are fairly big downsides to me.

Next Steps
I think it would be great to think about a way how we could have the new API without these downsides (but also without making the store interface big and confusing). I'll spend some time thinking about that and would love to hear anyone else's thoughts.

@dani-mp
Copy link
Contributor

dani-mp commented Apr 30, 2017

Hi, @Ben-G!

About the API discoverability problem that you mention, in my opinion the API resulting of these changes is clearer now, and partially solves what's mentioning on #150. I still think that we should be able to dispatch only plain Actions (you can always access the store and check the state before dispatching, and even put this logic in a middleware, which seems to be the idiomatic way of doing it), and StatedActionCreator(which I'd rename to ActionCreator) for redux-thunk like dispatches.

I totally see your point about the callbacks, though, and I also think that needs further discussion.

- Changed middleware signature
- Extracted helper dispatch functions to extension
- Added thunk middleware and helper dispatch funcs
callback?(self.state)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

@@ -117,49 +116,26 @@ open class Store<State: StateType>: StoreType {
" (e.g. from multiple threads)."
)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

return actionCreator(store, callback)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

if let actionCreator = actions.first as? ActionCreator<Any> {
return actionCreator(store)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

return result
}

public func dispatch<CallbackParam>(_ asyncActionCreator: @escaping AsyncActionCreator<CallbackParam>, callback: @escaping (CallbackParam) -> Void) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line Length Violation: Line should be 120 characters or less: currently 153 characters (line_length)

}
callback(param)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

next(param)
})
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

}
return result
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

callback?(self.state)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

@@ -117,49 +116,26 @@ open class Store<State: StateType>: StoreType {
" (e.g. from multiple threads)."
)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

return actionCreator(store, callback)
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

if let actionCreator = actions.first as? ActionCreator<Any> {
return actionCreator(store)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

return result
}

public func dispatch<CallbackParam>(_ asyncActionCreator: @escaping AsyncActionCreator<CallbackParam>, callback: @escaping (CallbackParam) -> Void) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line Length Violation: Line should be 120 characters or less: currently 153 characters (line_length)

}
callback(param)
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

next(param)
})
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

}
return result
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing Whitespace Violation: Lines should not have trailing whitespace. (trailing_whitespace)

@mpsnp
Copy link
Author

mpsnp commented May 2, 2017

Hey @Ben-G, @danielmartinprieto ! I've rethought this PR with respect to @Ben-G cons, and decided to extract functionality related to thunkMiddleware explicitly. New version allows us to write Async Action Creators in more familiar way to iOS developers like this:

func asyncActionCreator(store: DispatchingStoreType, completionHandler: @escaping (Int) -> Void) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 
        store.dispatch(SomeAction())
        completionHandler(10)
    }
}

As well as normal action creators, which allow us to return any type, for example promises:

func actionCreator(store: DispatchingStoreType) -> Promise<Void> {
    return firstly {
        doAnyAsyncStuff()
    }.then {
        store.dispatch(SomeAction())
    }
}

And here is example usage of action creators declared above:

let store = Store<AppState>(reducer: rootReducer, state: nil, middleware: [thunkMiddleware])

store.dispatch(asyncActionCreator) { result in
    print("Async with result = \(result)")
}

store.dispatch(actionCreator).then {
    print("Done!")
}.catch { (error) in
    print(error)
}

Also this implementation even allows us to extract thunkMiddleware to another framework, cause now it is real middleware.

Downsides

As you see, this required to change type of Middleware param, and add

func dispatch(_ params: Any...) -> Any

Maybe it is a pro too, because such change brings more flexibility in future and as i've shown in DispatchingStoreType.swift we always can implement helper dispatch methods in order to increase API discoverability.

@dani-mp
Copy link
Contributor

dani-mp commented May 3, 2017

I think it's a great idea to move this to a middleware, and even though we're not trying to replicate 1 to 1 the original Redux implementation because the differences between the two languages, it's nice if they follow the same principles if possible.

This made me think that maybe we don't need to change at all the public API of the current ReSwift version, or even better, we can simplify it removing both ActionCreator and AsyncActionCreator.

The idea would be to provide (maybe, as you said before, as another micro framework, as it is the original redux-thunk) both a new protocol and a middleware:

protocol Thunk: Action  {
    func body(dispatch: @escaping DispatchFunction, getState: @escaping () -> State?)
}

let thunkMiddleware: Middleware<State> = { dispatch, getState in
    return { next in
        return { action in
            switch action {
            case let thunk as Thunk:
                thunk.body(dispatch: dispatch, getState: getState)
            default:
                next(action)
            }
        }
    }
}

This means that the dispatch function can keep its current, simple signature (Action) -> Void, and we don't need to create others with different signatures to dispatch different things.

This also means that the thunks you create in your code are super flexible, as they can contain the information you want (data, callbacks...), as the current Actions do.

What do you think?

Example of usage, as in your example before:

struct SomeAction: Action {}

struct MyThunk: Thunk {
    let completionHandler: (Int) -> Void
    
    func body(dispatch: @escaping DispatchFunction, getState: @escaping () -> State?) {
        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
            dispatch(SomeAction())
            self.completionHandler(10)
        }
    }
}

let myThunk = MyThunk { someInt in
    print(someInt)
}
store.dispatch(myThunk)

@mpsnp
Copy link
Author

mpsnp commented May 3, 2017

@danielmartinprieto great idea of wrapping it into a Thunk protocol. And i absolutely agree that it'd be better to extract this functionality out of ReSwift core.

But this construction is not so comfortable in my opinion:

let myThunk = MyThunk { someInt in
    print(someInt)
}
store.dispatch(myThunk)

Also, i don't see in your proposal possibility to return any value out of the dispatch as in original redux (question to community: do we need it?).


What I recommend is: to change middleware func from

public typealias Middleware<State> = (@escaping DispatchFunction, @escaping () -> State?) -> (@escaping DispatchFunction) -> DispatchFunction

to

public typealias Middleware<State> = (DispatchingStoreType, @escaping () -> State?) -> (@escaping DispatchFunction) -> DispatchFunction

Because:

  1. It will not give to programmer possibility to write wrong middleware (for example use external dispatch function instead of internal one).
  2. It will be easier to use any extra dispatch methods which can be added as extensions to DispatchingStoreType.
  3. This way we can wrap Thunk construction into helper dispatch methods which will give us much cleaner syntax. And use these helper methods inside thunk's body.

But this still doesn't give us possibility to return any value from dispatch method. That's why proposal of second change:

// from
public typealias DispatchFunction = (Action) -> Void
// to
public typealias DispatchFunction = (Action) -> Any

And in order to keep Public API the same and don't break existing functionality:

protocol DispatchingStoreType {
    @discardableResult
    func dispatch(_ action: Action) -> Any
}

But this will give us full control over 'Dispatch - Middleware - return value' chain. And possibility to write helper dispatch methods of any signature (probably with generic return value)


In summary it's kind of crossed product of @danielmartinprieto last comment and lighter version of mine.


It is much more architectural change and i suppose we guys need more discussion to find the best way.

@dani-mp
Copy link
Contributor

dani-mp commented May 4, 2017

Hey!

About the Thunk constructor, a function is definitely cooler, but, TIL, Swift won't allow us to extend a non-nominal type (aka we can't make a function conforms to Action). Anyway, that example was more what Swift does with trailing closures, but with more params in the type, I think it pays off.

It's true I didn't think about dispatch returning something, because I thought that with a callback/completion handler in a Thunk would be enough. I tend to not use that approach and only update my views when the state changes. Also, I'm avoiding using thunks these days, and I dispatch only plain actions. If I want want any side effect, I use custom middleware, that dispatch actions themselves.

Anyway, the original Redux implementation returns the dispatched action, and redux-thunk returns whatever the thunks returns, so I think your last proposal covers these two cases. Would it be possible to make that return value generic instead of Any? BTW, I think dispatch was returning Any some time ago and it was changed to Void.

Apart from these, the takeaways of this whole thread for me are:

  • Thunks (the current action creators and async action creators) can be modelled better with a protocol that conforms Action.
  • Both the new protocol and the middleware that handles them can be extracted into a micro framework, as redux-thunk does, so the core of ReSwift gets simpler.
  • This simplifies as well the signature of dispatch, that only accepts something that conforms to Action. If we think about this, it's the same concept as in JavaScript where the constraint is that all objects need a type property, but modelled in a typed language.

@mpsnp
Copy link
Author

mpsnp commented May 5, 2017

Hey @danielmartinprieto!

Would it be possible to make that return value generic instead of Any?

I guess yes, just in order to return the same type of action which it received 😄 by simply:

func dispatch<A>(_ action: A) -> A where A: Action

But if we go this way, we need to save result of thunk in action itself somehow in order to return it in convenience dispatch function. Also regarding thunk constructor, i guess you misunderstood me, i mean some kind of this:

public typealias Thunk<T, State> = (_ store: DispatchingStoreType, _ getState: () -> State?) -> T
public typealias AsyncThunk<T, State> = (_ store: DispatchingStoreType,  _ getState: () -> State?, _ next: (T) -> Void) -> Void

protocol ThunkAction: Action {
    associatedtype Result
    associatedtype State
    var body: ActionCreator<Result, State> { get }
    var result: Result? { get set }
}

struct ConcreteThunkAction<T, S> {
    let body: ActionCreator<T, S>
    var result: T?
}

extension DispatchingStoreType {
    func dispatch<T, State>(_ thunk: Thunk<T, State>) -> T {
        let resultAction = dispatch(ConcreteThunkAction(body: thunk))
        guard let result = resultAction.result else { ... }
        return result
    }
}

And usage will be the same as in this comment, but with simplified DispatchFunction (thanks for your idea with Thunk protocol).


Summarising

So summarising whole that thread, correct me if i'm wrong. We need to:

  1. Change signature of DispatchFunction:
    public typealias DispatchFunction = (Action) -> Action
  2. Change signature of Middleware:
    public typealias Middleware<State> = (DispatchingStoreType, @escaping () -> State?) -> (@escaping DispatchFunction) -> DispatchFunction
  3. Leave the only dispatch method in ReSwift Core's Store and DispatchingStoreType and change it's signature to:
    func dispatch<A>(_ action: A) -> A where A: Action
  4. Remove all traces of "ActionCreators" from ReSwift Core and create another micro framework, for example named "ReSwiftThunk" which will contain:
    1. Thunk and AsyncThunk function typealiases which i've shown above
    2. ThunkAction and AsyncThunkAction protocols (or i even think we can just go with structs without protocols) which will wrap functions which i shown above and their results.
    3. Specific middleware for handling that stuff
    4. Extension for DispatchingStoreType which will contain convenience dispatch functions which will be wrappers around creation of ThunkAction and AsyncThunkAction and returning their values or providing trailing closures for async one (also shown above).

Haha, long comment! 😆

So, @Ben-G waiting your approval, if everything ok, we'll move forward 🚀

@dani-mp
Copy link
Contributor

dani-mp commented May 9, 2017

Hi, @mpsnp.

I thought you wanted to return something random (and therefore the Any), so I meant something like func dispatch<T>(_ action: A) -> T, but I don't know the implications of adding this generic to the function in order to connect the pieces later.

About the thunk library, I'd like to have something less complicated, more like my protocol Thunk: Action { ... }, I can't see why we would need Thunk and AsyncThunk, for example, because a Thunk is already supposed to be async, right? Also, I don't quite like the ThunkAction you propose with he associated types, and the extension for DispatchingStoreType, I find all this more complicated than it should be. Anyway, that's not important at all because maybe you have use cases that I didn't think of yet, or I'm missing things, and simplifying the core (this library) should be the goal here, and then we can investigate different approaches for middleware.

So summarising, your summary lgtm, just note the comment I made above about the dispatch function.

@beloso
Copy link

beloso commented Mar 26, 2018

Are there plans to merge this soon?

@DivineDominion
Copy link
Contributor

@danielmartinprieto with the ReSwift-Thunk lib on the horizon, can you have a second look at this and see if the new lib could scavenge some things from this PR?

@dani-mp
Copy link
Contributor

dani-mp commented Nov 14, 2018

I’ve reviewed the PR and I can’t find anything specific that you can’t do right now with the basic ReSwift types and middleware. ReSwift-Thunk would be just some help to those that prefer having their side effects closer to where they’re dispatched, but we decided to put it in a micro framework because it’s just a utility and not something foundational.

The main difference here would be the returning type of the dispatch function. As I mention in previous comments, the original Redux library returns (for convenience) the same action dispatched, but then custom middleware could change this and a) I don’t think it provides any specific benefit and b) it’s hard to type in a “swifty” way (without having to return Any, which I don’t think it’s cool). Also, I know this was the case here in ReSwift and it got changed, so I would vote for leaving it like that.

I hope this helps.

Sent with GitHawk

@DivineDominion
Copy link
Contributor

DivineDominion commented Nov 20, 2018

Sounds good to me. I'll close the issue for now and we move with the µ-framework as it is.

Sorry to turn down your first PR, @mpsnp! Maybe you can find a place to continue work in the ReSwift-Thunk library itself :)

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

Successfully merging this pull request may close these issues.

None yet

6 participants