ReSwift / ReactiveReSwift

Unidirectional Data Flow in Swift via FRP - Inspired by Elm
http://reswift.github.io/ReactiveReSwift
MIT License
136 stars 22 forks source link

Action Creators #3

Closed hartbit closed 7 years ago

hartbit commented 7 years ago

In the change log, it says:

Remove ActionCreator since this can easily be solved with Rx as a single value stream

Can you explain what you mean by that with a very small example?

Qata commented 7 years ago

Without being specific about the library, something like:

let signal: Signal<Action, NoError> = Signal.networkRequest("www.fake.com").map {
    AppAction.setFakeData($0)
}.flatMapError {
    Signal<Action, NoError>(AppAction.setFakeError($0))
}
dispatch(signal) // Only works if you've conformed Signal to StreamType
hartbit commented 7 years ago

Thanks! I understand now. But I think that a good old fashioned Action Creator could be very useful. Let me try to explain my reasoning:

I've always been annoyed about the lack of distinction between interface actions (i.e., as a result of tapping a button) and data actions, which might result from that interface action. Action creators seem great for this use case: an interface action-creator could spawn one or more data-oriented actions.

I really like how Redux Thunk works, because it means that the interface action is a real action that can be logged, serialised, etc...

How about a similar implementation where:

struct ActionCreator : Action {
    let closure: () -> ()
}

let actionCreatorMiddlewate = Middleware<State>().filter { getState, dispatch, action in
    if let actionCreator = action as? ActionCreator {
        actionCreators.closure()
        return true
    } else {
        return false
    }
}

struct SelectUser : ActionCreator {
    init(user: User) {
        closure = {
            mainStore.dispatch(LogOut())
            mainStore.dispatch(SetUser(user))
            navigationStore.dispatch(PopRoute())
        }
    }
}

I guess the fact that this can be easily coded with a simple middleware means it does not necessarily belong in the library, but I think it would be great to have a few simple middleware like this, the kind that everybody will use at some point or another.

Qata commented 7 years ago

Ah I see so you'd like a One to Many ActionCreator. I can see the utility of this but since the closure takes no arguments it's not really fitting as something to use in Middleware, you could just call it as a function from wherever you need to select a user.

An option that does fit in with Middleware would be something like:

/// Allows turning one action into many
public func map(_ transform: @escaping (GetState, Action) -> [Action]) -> Middleware<State> {
    return Middleware<State> { getState, dispatch, action in
        self.transform(getState, dispatch, action).flatMap {
            let actions = transform(getState, $0)
            actions.forEach(dispatch)
            return nil
        }
    }
}

This has the downside of triggering the previous middleware to be run again, so it's far from ideal.

Qata commented 7 years ago

@agentk I'd like to get your input on ActionCreator.

hartbit commented 7 years ago

you could just call it as a function from wherever you need to select a user.

But I would like to have the "interface action" treated as a normal action that goes through the store and middleware stack (even if it does not end up reaching the reducers). If means, for example, that if I have a logging middleware, it gets automatically logged as well.

since the closure takes no arguments it's not really fitting as something to use in Middleware

I think I don't quite understand how Middleware work in ReSwift then. I got a bit confused by your example piece of code. Can you point me to examples of middleware that might help me understand them better?

But coming back to Action Creators, my example was perhaps a bit too simple. The solution the a function returning a list of Actions is that it does not handle async operations. Here is the peace of code from redux-thunk which I'm trying to replicate:

function makeASandwich(forPerson, secretSauce) {
  return {
    type: 'MAKE_SANDWICH',
    forPerson,
    secretSauce
  };
}

function apologize(fromPerson, toPerson, error) {
  return {
    type: 'APOLOGIZE',
    fromPerson,
    toPerson,
    error
  };
}

function withdrawMoney(amount) {
  return {
    type: 'WITHDRAW',
    amount
  };
}

// Even without middleware, you can dispatch an action:
store.dispatch(withdrawMoney(100));

// But what do you do when you need to start an asynchronous action,
// such as an API call, or a router transition?

// Meet thunks.
// A thunk is a function that returns a function.
// This is a thunk.

function makeASandwichWithSecretSauce(forPerson) {

  // Invert control!
  // Return a function that accepts `dispatch` so we can dispatch later.
  // Thunk middleware knows how to turn thunk async actions into actions.

  return function (dispatch) {
    return fetchSecretSauce().then(
      sauce => dispatch(makeASandwich(forPerson, sauce)),
      error => dispatch(apologize('The Sandwich Shop', forPerson, error))
    );
  };
}

// Thunk middleware lets me dispatch thunk async actions
// as if they were actions!

store.dispatch(
  makeASandwichWithSecretSauce('Me')
);
Qata commented 7 years ago

I don't like the direction that adding ActionCreator back to the project would take us in, when it is possible to do something like this:

let person = …
let signal = Signal.fetchSecretSauce().map { sauce in
    makeASandwich(for: person, condiments: [sauce])
}.flatMapError {
    Signal<Action, NoError>(apologize(from: "The Sandwich Shop", for: person, error: $0))
}
store.dispatch(signal)

You can also flatMap streams if you wanted to send multiple Actions on success or failure, like this:

let signal = Signal.networkRequest("www.fake.com").flatMap { user in
    Signal<Action, NoError>(values: LogOut(), SetUser(user), PopRoute())
}
jondwillis commented 7 years ago

@hartbit This seems to have been addressed in the design of redux-observable, under the term of Epic

Check out https://youtu.be/AslncyG8whg?t=1370

From that intro video, in JS,

const autoCompleteEpic = (actions, store) =>
   actions.ofType('QUERY'(
      .debounceTime(500)
      .switchMap(action =>
         ajax('https://api.github.com/search/users?q=' + value)
            .map(playload => ({
               type: 'QUERY_FULFILLED',
               payload
             }))
             .takeUntil(actions.ofType('CANCEL_QUERY'))
             .catch(payload => [{
                type: 'QUERY_REJECTED',
                error: true,
                payload
              }])
    );

redux-observable implements Epics via middleware. Epics keep the sprit of how Reducers are designed in Redux (and ReSwift), but depart a bit from how @Qata has envisioned Observable Actions above. That is, Epics are defined at the top-level, combined, and are fed into the Store at instantiation via Middleware, and Actions stay dumb and simple (and preferably structs.) It should be relatively simple to replicate redux-observable on top of ReactiveReSwift, and I think that could/should be done after some more discussion.

On that note, is there any prior art from Reactive + Redux implementations where you can dispatch Observable Actions like in ReactiveReSwift? Actions not being serializable, "plain" objects - and I would argue that Observables are not plain- seems to go against one of the core tenants of Redux. See https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367#.ccx35yjug

Redux offers a tradeoff. It asks you to: Describe application state as plain objects and arrays. Describe changes in the system as plain objects. Describe the logic for handling changes as pure functions.

And thus "time travel" and all of its benefits becomes very difficult, but perhaps that is true of all side-effects.

jondwillis commented 7 years ago

Fluorine is also very interesting. https://github.com/philpl/fluorine