How to handle complex side-effects in Redux?

2019-01-29 19:18发布

问题:

I've been struggling for hours to finding a solution to this problem...

I am developing a game with an online scoreboard. The player can log in and log out at any time. After finishing a game, the player will see the scoreboard, and see their own rank, and the score will be submitted automatically.

The scoreboard shows the player’s ranking, and the leaderboard.

The scoreboard is used both when the user finishes playing (to submit a score), and when the user just wants to check out their ranking.

This is where the logic becomes very complicated:

  • If the user is logged in, then the score will be submitted first. After the new record is saved then the scoreboard will be loaded.

  • Otherwise, the scoreboard will be loaded immediately. The player will be given an option to log in or register. After that, the score will be submitted, and then the scoreboard will be refreshed again.

  • However, if there is no score to submit (just viewing the high score table). In this case, the player’s existing record is simply downloaded. But since this action does not affect the scoreboard, both the scoreboard and the player’s record should be downloaded simultaneously.

  • There is an unlimited number of levels. Each level has a different scoreboard. When the user views a scoreboard, then the user is ‘observing’ that scoreboard. When it is closed, the user stops observing it.

  • The user can log in and log out at any time. If the user logs out, the user’s ranking should disappear, and if the user logs in as another account, then the ranking information for that account should be fetched and displayed.

    ...but this fetching this information should only take place for the scoreboard whose user is currently observing.

  • For viewing operations, the results should be cached in-memory, so that if user re-subscribes to the same scoreboard, there will be no fetching. However, if there is a score being submitted, the cache should not be used.

  • Any of these network operations may fail, and the player must be able to retry them.

  • These operations should be atomic. All the states should be updated in one go (no intermediate states).

Currently, I am able to solve this using Bacon.js (a functional reactive programming library), as it comes with atomic update support. The code is quite concise, but right now it is a messy unpredictable spaghetti code.

I started looking at Redux. So I tried to structure the store, and came up with something like this (in YAMLish syntax):

user: (user information)
record:
  level1:
    status: (loading / completed / error)
    data:   (record data)
    error:  (error / null)
scoreboard:
  level1:
    status: (loading / completed / error)
    data:
      - (record data)
      - (record data)
      - (record data)
    error:  (error / null)

The problem becomes: where do I put the side-effects.

For side-effect-free actions, this becomes very easy. For instance, on LOGOUT action, the record reducer could simply blast all the records off.

However, some actions do have side effect. For example, if I am not logged in before submitting the score, then I log in successfully, the SET_USER action saves the user into the store.

But because I have a score to submit, this SET_USER action must also cause an AJAX request to be fired off, and at the same time, set the record.levelN.status to loading.

The question is: how do I signify that a side-effects (score submission) should take place when I log in in an atomic way?

In Elm architecture, an updater can also emit side-effects when using the form of Action -> Model -> (Model, Effects Action), but in Redux, it’s just (State, Action) -> State.

From the Async Actions docs, the way they recommend is to put them in an action creator. Does this means that the logic of submitting the score will have to be put in the action creator for a successful login action as well?

function login (options) {
  return (dispatch) => {
    service.login(options).then(user => dispatch(setUser(user)))
  }
}

function setUser (user) {
  return (dispatch, getState) => {
    dispatch({ type: 'SET_USER', user })
    let scoreboards = getObservedScoreboards(getState())
    for (let scoreboard of scoreboards) {
      service.loadUserRanking(scoreboard.level)
    }
  }
}

I find this a bit odd, because the code responsible for this chain reaction now exists in 2 places:

  1. In the reducer. When SET_USER action is dispatched, the record reducer must also set the status of the records belonging to the observed scoreboards to loading.
  2. In the action creator, which performs the actual side-effect of fetching/submitting score.

It also seems that I have to manually keep track of all the active observers. Whereas in Bacon.js version, I did something like this:

Bacon.once() // When first observing the scoreboard
.merge(resubmit口) // When resubmitting because of network error
.merge(user川.changes().filter(user => !!user).first()) // When user logs in (but only once)
.flatMapLatest(submitOrGetRanking(data))

The actual Bacon code is a lot longer, because of the all the complex rules above, that made the Bacon version barely-readable.

But Bacon kept track of all active subscriptions automatically. This led me to start questioning that it might not be worth the switch, because rewriting this to Redux would require a lot of manual handling. Can anyone suggest some pointer?

回答1:

When you want complex async dependencies, just use Bacon, Rx, channels, sagas, or another asynchronous abstraction. You can use them with or without Redux. Example with Redux:

observeSomething()
  .flatMap(someTransformation)
  .filter(someFilter)
  .map(createActionSomehow)
  .subscribe(store.dispatch);

You can compose your asynchronous actions any way you like—the only important part is that eventually they turn into store.dispatch(action) calls.

Redux Thunk is enough for simple apps, but as your async needs get more sophisticated, you need to use a real asynchronous composition abstraction, and Redux doesn't care which one you use.


Update: Some time has passed, and a few new solutions have emerged. I suggest you to check out Redux Saga which has become a fairly popular solution for async control flow in Redux.



回答2:

Edit: there now a redux-saga project inspired by these ideas

Here are some nice resources

  • Comparison of Redux-saga and Redux-thunk
  • Redux-saga vs Redux-thunk with async/await
  • Managing processes in Redux Saga
  • From actionsCreators to Sagas
  • Snake game implemented with Redux-saga

Flux / Redux is inspired from backend event stream processing (whatever the name is: eventsourcing, CQRS, CEP, lambda architecture...).

We can compare ActionCreators/Actions of Flux to Commands/Events (terminology usually used in backend systems).

In these backend architectures, we use a pattern that is often called a Saga, or Process Manager. Basically it is a piece in the system that receives events, may manage its own state, and then may issue new commands. To make it simple, it is a bit like implementing IFTTT (If-This-Then-That).

You can implement this with FRP and BaconJS, but you can also implement this on top of Redux reducers.

function submitScoreAfterLoginSaga(action, state = {}) {  
  switch (action.type) {

    case SCORE_RECORDED:
      // We record the score for later use if USER_LOGGED_IN is fired
      state = Object.assign({}, state, {score: action.score}

    case USER_LOGGED_IN: 
      if ( state.score ) {
        // Trigger ActionCreator here to submit that score
        dispatch(sendScore(state.score))
      } 
    break;

  }
  return state;
}

To make it clear: the state computed from reducers to drive React renderings should absolutly stay pure! But not all state of your app has the purpose of triggering renderings, and this is the case here where the need is to synchronise different decoupled parts of your app with complex rules. The state of this "saga" should not trigger a React rendering!

I don't think Redux provide anything to support this pattern but you can probably implement it by yourself easily.

I've done this in our startup framework and this pattern works fine. It permits us to handle IFTTT things like:

  • When user onboarding is active and user close Popup1, then open Popup2, and display some hint tooltip.

  • When user is using mobile website and opens Menu2, then close Menu1

IMPORTANT: if you are using undo/redo/replay features of some frameworks like Redux, it is important that during a replay of an event log, all these sagas are unwired, because you don't want new events to be fired during the replay!