Fixing Out of Sync React Component Action Creator Calls:





Overview

I have been developing a reddit clone website, reReddit, built with a Django Rest Framework api backend and a React single page app for the frontend. When I was nearing the completion of the implmentation of those reddit-like nested comments I noticed that sometimes, when I switched from post to post the comments would not update to match the new post; instead the comments that belong to the previous post would remain.

This was pretty alarming as I am trying to get this thing up and running soon to have an example project to show off. With the help of the redux developer tools I was able to figure out what was going on and implement a, somewhat hacky, fix pretty quickly.

Context

First I will lay out some simplified example code of what was causing the problem. I don't want to write up an entire application but here are the relevant facts about my project:

  • Uses redux and react-router
  • The redux store is persisted.
  • As I mentioned above there is a DRF backend that houses all of the data, here I will just show the api calls and won't discuss much more about that.

In my main index file I have the router setup with several routes, often that take arguments (here I am omitting other unecessary stuff like the redux provider).

// index.js

ReactDOM.render(
  <BrowserRouter>
    <Switch>
      <Route
        exact
        path="/r/:subredditTitle/:postID"
        component=PostDetailContainer
      />
      // ... Other routes
    </Switch>
  </BrowserRouter/>
)

The PostDetailContainer component reads the redux store for information on the current post and dispatches an api action to update that post information if the route has updated.

// PostDetailContainer.js

class PostDetailContainer extends Component {
  componentDidMount() {
    const postId = this.props.match.params.postId || null;
    this.props.fetchPostDetail(postId);
  }
  
  render() {
    return <PostDetail {...this.props} />;
  }
}

const mapStateToProps = (state) => (
  {
    postBody: getPostDetailBody(state),
    postTitle: getPostDetailTitle(state),
    postPoster: getPostDetailPosterUsername(state),
    postPk: getPostDetailPk(state),
  }
)

const mapDispatchToProps = (dispatch) => (
  {
    fetchPostDetail: (postId) => dispatch(makePostDetailRequest(postId)),
  }
)

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(PostDetailContainer);
}

The makePostDetailRequest function is an action creator that, in conjunction with some middleware, is used to dispatch some api requests and corresponding actions, the first will be FETCH_POST_DETAIL_REQUEST when the request is sent, followed by either FETCH_POST_DETAIL_SUCCESS or FETCH_POST_DETAIL_FAILURE, depending on the server response. Note that the postId being used for the api request is from react router this.props.match.params.postId and not the one we saved previously in the redux store. If we used that one we would just keep retrieving the same data over and over again regardless of the current route.

The PostDetail presentational component has a lot of styling and uses some of the other props you see in PostDetailContainer that aren't really relevant to the current discussion like postBody and postTitle. What is important is that it renders the CommentListContainer component.

// PostDetail.js

const PostDetail (props) => (
  
  <div className="...">
    // Some other styling and a place for the post title and body
    // Here the list of nested comments in rendered
    <CommentListContainer />
  </div>
  )

Now we are finally getting to where the problem occurred. Here is a simplified version of my original implementation of the CommentListContainer component.

// CommentListContainer.js

const CommentListContainer extends Component {
  
  componentDidMount() {
    this.props.fetchCommentList(this.props.postPk)
  }
  
  render() {
    return <CommentList {...this.props} />
  }
}

const mapStateToProps = (state) => ({
  postPk: getPostDetailPk(state),
  // some other stuff for CommentList, not important here
  })
  
const  mapDispatchToProps = (dispatch) => ({
  fetchCommentList: (postId) => dispatch(makeCommentListRequest(postId)),
  })
  
export default connect(
  mapStateToProps,
  mapDispatchToProps,
  )(CommentListContainer);

Again we are just importing an action creator that gets the api work done, makeCommentListRequest. Just like before it has the potential to dispatch three different actions, FETCH_POST_COMMENT_TREES_REQUEST, FETCH_POST_COMMENT_TREES_SUCCESS, and FETCH_POST_COMMENT_TREES_FAILURE. It's important to note that the postId passed to that function is the one from the redux store, that is updated by PostDetailContainer, and not the one directly from the route/url. The CommentList component is not really important to the discussion, once everything is loaded is just displays the nested comments.

The Problem

Lets say that I created a link between two different post detail pages. When I follow the link and take a look at the redux actions dispatched, with the redux developer tools, I will see something like this:

Since the CommentListContainer is the mostly deeply nested component it will be mounted first and the corresponding action, FETCH_POST_COMMENT_TREES_REQUEST, will be dispatched first. This is an issue because FETCH_POST_DETAIL_REQUEST has not been dispatched yet, let alone FETCH_POST_DETAIL_SUCCESS. This means that the postPk in the redux store has not been updated; it will still contain all of the information about the previous post, most notably the postPk/postId. So when that first api call for the comment lists is made it is fetching the wrong comments.

This actually worked out most of time because I had rendered a loading component that would take the place of CommentListContainer in PostDetail in between the post detail request and success. That means that the CommentListContainer was being remounted once the fresh post information was set in the redux store an so componentDidMount was called and a new request went out with the appropriate postId.

This is pretty sloppy obviously and I didn't notice it until the system really broke down as is the case in the redux action list above. There you can see the original FETCH_POST_COMMENT_TREES_REQUEST and the second one after FETCH_POST_DETAIL_SUCCESS is dispatched. Unfortunately the original comment trees request takes way longer than the second for some reason and so the second FETCH_POST_COMMENT_TREES_SUCCESS actually corresponds the the response of the original api call, i.e. the one with the wrong postPk. Since this is last in the list the redux store is left with the wrong information about the current post's comments and that is what gets rendered.

The Solution

At least in part this issue occurred because I did not adhear to the 'single source of truth' principle of react. I have two different sources for what the current post id is. The router params/props are attractive because they update the instance that link is followed, before everything is rendered. The issue with using them though is that I would either have to prop-drill to or use withRouter on all of the components that need that information (which is quite a few in my full app).

Storing the post id in redux is really nice because that is where everything else is located, you can see that my general pattern above is to have pretty tight coupling of components all through the tree to the store. It would be a shame to have to handle this one prop/variable differently. The obvious downside to using redux is that it causes the problem I described above. When a child component needs a redux variable that is updated by a parent that update will occur too late.

I had a few ideas on how to fix this and ended up going with the easiest, most expedient and probably least professional way. I would like to talk about the other option before I describe what I did.

I think it would be best to dispatch an action immediately upon a route change. I searched around briefly and couldn't see an easy way to do that. I am wondering if that is frowned upon for some reason. That would solve this issue because the redux store postId would be updated before the comment list api call is made. Then I could leave the rest of the code as is. In this situation the source of truth for the post id would be the redux store, and it would actually be accurate.

I ended up going with the solution that I alluded to earlier. I just used the withRouter wrapper from react-router on my CommentListContainer to access the route information. > Note that I could have just passed the route props from PostDetail to > CommentListContainer as well. In reality there are more layers to > consider and I think withRouter is a little simpler.

This solves the problem but I would also like to avoid that initial unecessary api call, the component will be remounted anyways, I would rather just wait to make that request. So I make sure that the redux store and router and in agreement before making the request. This prevents the initial request, even though it could now be made with the proper id.

// CommentListContainer.js
import { withRouter } from 'react-router';

const CommentListContainer extends Component {
  
  componentDidMount() {
    // wait to submit comment list request until postPk is updated in redux store
    // the router pk (params.postId) is updated immediately.
    
    if (Number(this.props.match.params.postId) === this.props.postPk) {
      this.props.fetchCommentList(this.props.postPk)  
    }
  }
  
  render() {
    return <CommentList {...this.props} />
  }
}

const mapStateToProps = (state) => ({
  postPk: getPostDetailPk(state),
  // some other stuff for CommentList, not important here
  })
  
const  mapDispatchToProps = (dispatch) => ({
  fetchCommentList: (postId) => dispatch(makeCommentListRequest(postId)),
  })
  
// Use withRouter to inject the match params from react router
export default withRouter(connect(
  mapStateToProps,
  mapDispatchToProps
)(CommentTreeListContainer));