Search code examples
reactjsreact-routerflux

ReactJS/Router/Flux: Warning: Can only update a mounted or mounting component


Note: This is a rather long description, so do bear with me. With the number of components that is entailed for a reproducible JSBin or similar implementation, a written description seemed to be a safer and workable choice.

TL;DR

In my Flux(3.1.2) / React(15.4.2)/ React-Router(3.0.2) app, i always seem to get the following error/warning ONLY when i navigate between routes:

warning.js:36 Warning: setState(...): Can only update a mounted or mounting component. This usually means you called setState() on an unmounted component. This is a no-op. Please check the code for the AllVideos component.

My effort

I researched a bit and most of the information seems to point to it being a case of listened events not being removed. I tried all variations of suggestions but none seem to work.

Some background information

My effort is a very simple app being developed mainly to ensure hands-on learning/practice experience since i am quite new to ReactJS.

A Node/ Express server in the backend serves data (something like a REST endpoint) consumed by my ReactJS app aided by React-router and Flux.

The initial data is stored in an SQLite DB that i return back as JSON whenever requested. For example on loading the app, i initiate an AJAX call(via Axios) to my server to return back a subset of results in JSON which is then stored in my Flux store. Stateful components will use this data to manipulate the view as required.

My Routes.js has this structure:

const Routes = (props) => ( 
  <Router {...props}>
   <Route component={MainContainer}> 
    <Route path="/" component={App}> 
     <IndexRoute component={Home} />
     <Route path="videos" component={Videos}></Route>
     <Route path="videos/:mediaid" component={MediaDetails} />
   </Route>
   <Route path="*" component={NotFound} />
  </Route>
</Router>
);  
export default Routes;

The flow

The <MainContainer> component is a purely layout component that displays a <TopHeader> component and a <Sidebar> component, both of which i want consistently persistent across all pages. The additional {this.props.children} in my <MainContainer>(code is provided towards the end of this description) component will pull in my main content which includes components like <Home>, <AllVideos> etc.

The <Home> component(reachable via localhost:3000) is currently just placeholder - i am concentrating on the <AllVideos> component (reachable via localhost:3000/videos) for now. This <MainContainer> component triggers an action in ComponentWillMount() that fires off an AJAX call that i will then receive in my store. I populate my initial store object and emit a onChange event that I listen for in my <AllVideos> component

The <AllVideos> component has listeners for the initial AJAX call (made in the <MainContainers> component as mentioned above) as well as listeners for a onFilter event that i emit when certain filters are activated by the user using form elements like dropdowns, inputs, checkboxes etc (in a separate nested component called <SearchAndFilter>).

The <AllVideos> component has a nested <Videos> component(which currently does nothing but passing on props to its nested <VideosBody> component).

Inside the nested <Videos> component i have a nested <VideosBody> component which maps through the videos object received via props and creates several <VideoCard> components(to display data per video).

Additionally in the <Videos> component, i have a single <SearchAndFilter> component (sitting above all the rendered <VieoCard> components)that will display a few form elements for some simple client-side filtering of the data/videos.

The <AllVideos> nesting is thus:

<AllVideos> (listens for the initial AJAX event triggered from a parent component as well as Filter events triggered from a child component, passes props of its state to its nested children)
  <Videos> (currently just relays props received - may do other things later)
    <VideosBody> (uses props to build the videocards via looping through the data)
        <SearchAndFilter /> (onchange of form element values, fire-and-forget actions are triggered that are dispatched to the store where a Filter event is emitted once the processing is done (i use `promises` since there are keydown events)
        <VideoCard /><VideoCard /><VideoCard /><VideoCard /> etc (also has a <Link to> to the <MediaDetails> object
    </VideosBody>
  </Videos>
</AllVideos>

When i hit http://localhost:3000, the "/" route is activated and the <Home> container display fine.

I navigate(<Link to>) from the Sidebar to the videos(http://localhost:3000/videos) link which displays the component(and thus all nested components inside this). It works fine, i see my videos rendered as expected. I attempt to filter the data using the form elements inside the component. The filteration works but i get the warning/error that i mentioned in the beginning, which in turn points to the onFilter() method as the culprit (i.e the method that triggers when the Filter event is emitted).

When i directly hit http://localhost:3000/videos, my component displays with correct data. My filters work fine and no errors or warnings are emitted. It works just as i want it to.

It seems to be an issue that emerges when i navigate between routes. I logged the onFilter() method and noticed that the filter event is triggered by the number of times i have navigated the routes. If i do a Home > Videos it gets correctly triggered once. I navigate back to Home and come back to videos and re-initiate the filter and now the onFilter() method is now called twice (with twice the errors). This keeps increasing with each navigation performed.

I noticed that the ComponentWillUnMount() doesn't seem to fire in any of the effected components when i navigate between videos and anywhere else. This method is where i remove the event listeners. However, the ComponentWillMount() and ComponentDidMount() seems to fire with each navigation initiated in all the components. A similar issue is described here (sub-routes)

My MainContainer component(for layout):

export default class MainContainer extends React.Component{
...
    componentDidMount(){
        CatalogActions.GetCatalog(); //gets my initial AJAX call going
    };
...
render(){
        return(
        <div className="container-fluid">
            <header className="row primary-header">
                <TopHeader />
            </header>
            <aside className="row primary-aside">Aside here </aside>
            <main className="row">
                <Sidebar />
                <div className="col-md-9 no-float">
                    {this.props.children}
                </div>
            </main>

        </div>
        );
    };

My App component:

export default class App extends Component {
constructor(props){

    super(props);

    this.state = {

  }
};

...
...

render() {
  return (
    <div>
        {this.props.children}
    </div>
    );
    };
};

The AllVideos component:

export default class AllVideos extends React.Component {
    constructor(props){
        super(props);
        this.state={
           // isVisible: true
             initialDataUpdated:false,
             isFiltered: false,
            "catalog": []
        };
        this.onChange= this.onChange.bind(this);
        this.onError=this.onError.bind(this);
        this.onFilter=this.onFilter.bind(this);
        this.onFilterError=this.onFilterError.bind(this);
    };
    componentDidMount(){
        if(CatalogStore.getList().length>0){
            this.setState({
            "catalog": CatalogStore.getList()[0].videos
        })
        }        
        CatalogStore.addChangeListener(this.onChange);
        CatalogStore.addErrorListener(this.onError);
        CatalogStore.addFilterListener(this.onFilter);
        CatalogStore.addFilterErrorListener(this.onFilterError);
    };
    componentWillUnMount(){ //this method never gets called
        CatalogStore.removeChangeListener(this.onChange);
        CatalogStore.removeErrorListener(this.onError);
        CatalogStore.removeFilterListener(this.onFilter);
        CatalogStore.removeFilterListener(this.onFilterError);
    };
    onChange(){
        //This gets triggered after my initial AJAX call(triggered in the MainContainer component) succeeds
    //Works fine and state is set
        this.setState({
            initialDataUpdated: true,
            "catalog": CatalogStore.getList()[0].videos
        })
    };
    onError(str){
        this.setState({
            initialDataUpdated: false,
            "catalog": CatalogStore.getList()[0].videos

        });
    };
    componentWillMount(){        
    };
    onFilter(){//This is the method that the error points to. The setState does seem to work however, but with accumulating warnings and calls (eg 3 calls if i navigate between different components 3 times instead of the correct 1 that happens when i come to the URL directly without any route navigation)
        this.setState({
            //isFiltered: true,
            catalog: CatalogStore.getFilteredData()
        })

    }
    onFilterError(){
        this.setState({
            //isFiltered: false,
            catalog: CatalogStore.getFilteredData()
        })
    }

/*componentWillReceiveProps(nextProps){
        //console.log(this.props);
    };
    shouldComponentUpdate(){
        //return this.state.catalog.length>0
        return true
    }
    componentWillUpdate(){
        //console.log("Will update")
    }    
*/
    render(){

            return(
            <div style={VideosTabStyle}>
                This is the All Videos tab.
                <Videos data={this.state.catalog} />

            </div>
        ); 
    };

The Dispatched events received:

_videoStore.dispatchToken = CatalogDispatcher.register(function(payload) {
    var action = payload.action;
    switch (action.actionType) {
        case CatalogConstants.GET_INITIAL_DATA:
            _videoStore.list.push(action.data);
            _filteredStore.videos.push(action.data.videos);
            _filteredStore.music.push(action.data.music);
            _filteredStore.pictures.push(action.data.pictures);
            catalogStore.emit(CHANGE_EVENT);
            break;
        case CatalogConstants.GET_INITIAL_DATA_ERROR:
            catalogStore.emit(ERROR_EVENT);
            break;
        case CatalogConstants.SEARCH_AND_FILTER_DATA:
            catalogStore.searchFilterData(action.data).then(function() {
              //emits the event after processing - works fine
                catalogStore.emit(FILTER_EVENT);
            },function(err){
                catalogStore.emit(FILTER_ERROR_EVENT);
            });
            break;
        .....
        .....    
        default:
            return true;
    };
});

The event emiiters:

var catalogStore = Object.assign({}, EventEmitter.prototype, {
    addChangeListener: function(cb) {
        this.on(CHANGE_EVENT, cb);
    },
    removeChangeListener: function(cb) {
        this.removeListener(CHANGE_EVENT, cb);
    },
    addErrorListener: function(cb) {
        this.on(ERROR_EVENT, cb);
    },
    removeErrorListener: function(cb) {
        this.removeListener(ERROR_EVENT, cb);
    },

    addFilterListener: function(cb) {
        this.on(FILTER_EVENT, cb);
    },
    removeFilterListener: function(cb) {
        this.removeListener(FILTER_EVENT, cb);
    },
    addFilterErrorListener: function(cb) {
        this.on(FILTER_ERROR_EVENT, cb);
    },
    removeFilterErrorListener: function(cb) {
        this.removeListener(FILTER_ERROR_EVENT, cb);
    },
    .....
    .....
}

Solution

  • I'm on phone so I can't do any testing of your code, but what if you lowercased the 'M' in componentWillUnmount?