Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Redux: request into success or error flow from Component (using redux-saga)

This is the one thing that I haven't found a standard solution to yet.

I have my store setup with redux-saga for side effect handling, I dispatch an action (that has async side effects) from a component, and now want the component to do something once the side effects are handled (for example navigate to another route/screen, popup a modal/toast or anything else).

Also I want to display a loading indicator or any errors on failure.

Before redux, this kind of flow was straight forward, it'd look something like this:

try {
  this.setState({loading: true});
  const result = await doSomeRequest();
  this.setState({item: result, loading: false});
} catch (e) {
  this.setState({loading: false, error: e});
}

With redux, I'd typically want to dispatch an action initiating the flow and have all related information in the store, to allow many components to listen to what is happening.

I could have 3 actions, 'requested', 'success', 'failed'. In my component I would dispatch the requested action. The responsible saga will then dispatch either the 'success' or 'failed' action upon handling 'requested'.

My Component will reflect on the changes. But I haven't found out a standard way to figure out if the action has completed. Maybe the store hasn't updated as result of the async action (NO-OP, but loading state would still change I guess). But the action still succeeded, and I want to do something like navigate to another screen.

I really tried finding this kind of (seemingly common) scenario in the redux docs, redux-saga docs or Stackoverflow/Google, but no success.

Note: also with redux-thunk I think this behaviour is straight forward to achieve, since I can just .then on an async action dispatch and would receive the success action or the error action in catch (correct me if I'm wrong, never really used thunk). But I haven't seen the same behaviour achieved with redux-saga yet.

I've come up with 3 concrete solutions:

  1. Most primitive solution, handling only the 'success'/'failed' actions from the component. Now this solution I am not a big fan of. In my concrete implementation there is no action that indicates that the async request has been started. The side effects are handled right there in the Component, instead of being abstracted away within a saga. Lots of potential code repitition.

  2. Running a one time saga right before dispatching the request action, that races the 'success'/'failed' actions against each other and allows to react on the first occurring action. For this I've written a helper that abstracts the running of the saga away: https://github.com/milanju/redux-post-handling-example/blob/master/src/watchNext.js This example I like a lot more than 1. since it's simple and declarative. Though I don't know if creating a saga during run time like this has any negative consequences, or maybe there is another 'proper' way to achieve what I'm doing with redux-saga?

  3. Putting everything related to the action (loading, successFlag, error) into the store, and reacting in componentWillReceiveProps on action changes ((!this.props.success && nextProps.success)) means the action has completed successful). This is similar to the second example, but works with whatever side effect handling solution you choose. Maybe I'm overseeing something like the detection of an action succeeding not working if props hail in very fast and props going into componentWillReceiveProps will 'pile up' and the component skips the transition from non-success to success altogether?

Please feel free to have a look at the example project I've created for this question, that has the full example solutions implemented: https://github.com/milanju/redux-post-handling-example

I would love some input on the methods I use to handle the described flow of actions.

  1. Am I misunderstanding something here? The 'solutions' I came up with were not straight forward to me at all. Maybe I'm looking at the problem from the wrong angle.
  2. Are there any issues with the examples above?
  3. Are there any best practice or standard solutions for this problem?
  4. How do you handle the described flow?

Thanks for reading.

like image 674
Milant Avatar asked Jun 27 '17 20:06

Milant


3 Answers

If I understand your question correctly, you want your component to take action based on actions fired off by your saga. This would typically happen in componentWillReceiveProps - that method is called with the new props while the old props are still available via this.props.

You then compare the state (requested / succeeded / failed) to the old state, and handle the various transitions accordingly.

Let me know if I've misinterpreted something.

like image 77
Richard Szalay Avatar answered Nov 16 '22 02:11

Richard Szalay


I achieved the point of having an asynchronous action callback in a component using saga the following way:

class CallbackableComponent extends Component {
  constructor() {
    super()
    this.state = {
      asyncActionId: null,
    }
  }

  onTriggerAction = event => {
    if (this.state.asyncActionId) return; // Only once at a time
    const asyncActionId = randomHash();
    this.setState({
      asyncActionId
    })
    this.props.asyncActionWithId({
      actionId: asyncActionId,
      ...whateverParams
    })
  }

  static getDerivedStateFromProps(newProps, prevState) {
    if (prevState.asyncActionId) {
      const returnedQuery = newProps.queries.find(q => q.id === prevState.asyncActionId)
      return {
        asyncActionId: get(returnedQuery, 'status', '') === 'PENDING' ? returnedQuery.id : null
      }
    }
    return null;
  }
}

With the queries reducer like this:

import get from 'lodash.get'

const PENDING = 'PENDING'
const SUCCESS = 'SUCCESS'
const FAIL = 'FAIL'

export default (state = [], action) => {
  const id = get(action, 'config.actionId')
  if (/REQUEST_DATA_(POST|PUT|DELETE|PATCH)_(.*)/.test(action.type)) {
    return state.concat({
      id,
      status: PENDING,
    })
  } else if (
    /(SUCCESS|FAIL)_DATA_(GET|POST|PUT|PATCH)_(.*)/.test(action.type)
  ) {
    return state
      .filter(s => s.status !== PENDING) // Delete the finished ones (SUCCESS/FAIL) in the next cycle
      .map(
        s =>
          s.id === id
            ? {
                id,
                status: action.type.indexOf(SUCCESS) === 0 ? SUCCESS : FAIL,
              }
            : s
      )
  }
  return state
}

In the end, my CallbackableComponent knows if the query has finished by checking if this.state.asyncActionId is present.

But this comes at the cost of:

  1. Adding an entry to the store (though this is inevitable)
  2. Adding a lot of complexity on the component.

I would expect:

  1. the asyncActionId logic to be held on the saga side (eg. when the async action is connected using mapActionsToProps, it returns the id when called: const asyncActionId = this.props.asyncAction(params), like setTimeout)
  2. the store part to be abstracted by redux-saga, just like react-router is in charge of adding the current route to the store.

For now, I can't see a cleaner way to achieve this. But I would love to get some insights on this!

like image 43
Augustin Riedinger Avatar answered Nov 16 '22 02:11

Augustin Riedinger


Maybe I didn't understand the problem your facing but I guess they meant the client would look something like this:

mapStateToProps = state => {
 return {
   loading: state.loading,
   error  : state.error,
   data   : state.data 
 };
}

and the component will render like:

return(
   {this.props.loading  && <span>loading</span>}
   {!this.props.loading && <span>{this.props.data}</span>}
   {this.props.error    && <span>error!</span>}
)

and when requested action is dispatched the its reducer will update the store state to be {loading: true, error: null}.

and when succeeded action is dispatched the its reducer will update the store state to be {loading: false, error: null, data: results}.

and when failed action is dispatched the its reducer will update the store state to be {loading: false, error: theError}.

this way you won't have to use componentWillReceiveProps . I hope it was clear.

like image 33
Nirit Levi Avatar answered Nov 16 '22 01:11

Nirit Levi