15

This is driving me crazy. When I try to use React Router's Link within a nested route, the link updates in the browser but the view isn't changing. Yet if I refresh the page to the link, it does. Somehow, the component isn't updating when it should (or at least that's the goal).

Here's what my links look like (prev/next-item are really vars):

<Link to={'/portfolio/previous-item'}>
    <button className="button button-xs">Previous</button>
</Link>
<Link to={'/portfolio/next-item'}>
    <button className="button button-xs">Next</button>
</Link>

A hacky solution is to manaully call a forceUpate() like:

<Link onClick={this.forceUpdate} to={'/portfolio/next-item'}>
    <button className="button button-xs">Next</button>
</Link>

That works, but causes a full page refresh, which I don't want and an error:

ReactComponent.js:85 Uncaught TypeError: Cannot read property 'enqueueForceUpdate' of undefined

I've searched high and low for an answer and the closest I could come is this: https://github.com/reactjs/react-router/issues/880. But it's old and I'm not using the pure render mixin.

Here are my relevant routes:

<Route component={App}>
    <Route path='/' component={Home}>
        <Route path="/index:hashRoute" component={Home} />
    </Route>
    <Route path="/portfolio" component={PortfolioDetail} >
        <Route path="/portfolio/:slug" component={PortfolioItemDetail} />
    </Route>
    <Route path="*" component={NoMatch} />
</Route>

For whatever reason, calling Link is not causing the component to remount which needs to happen in order to fetch the content for the new view. It does call componentDidUpdate, and I'm sure I could check for a url slug change and then trigger my ajax call/view update there, but it seems like this shouldn't be needed.

EDIT (more of the relevant code):

PortfolioDetail.js

import React, {Component} from 'react';
import { browserHistory } from 'react-router'
import {connect} from 'react-redux';
import Loader from '../components/common/loader';
import PortfolioItemDetail from '../components/portfolio-detail/portfolioItemDetail';
import * as portfolioActions  from '../actions/portfolio';

export default class PortfolioDetail extends Component {

    static readyOnActions(dispatch, params) {
        // this action fires when rendering on the server then again with each componentDidMount. 
        // but not firing with Link...
        return Promise.all([
            dispatch(portfolioActions.fetchPortfolioDetailIfNeeded(params.slug))
        ]);
    }

    componentDidMount() {
        // react-router Link is not causing this event to fire
        const {dispatch, params} = this.props;
        PortfolioDetail.readyOnActions(dispatch, params);
    }

    componentWillUnmount() {
        // react-router Link is not causing this event to fire
        this.props.dispatch(portfolioActions.resetPortfolioDetail());
    }

    renderPortfolioItemDetail(browserHistory) {
        const {DetailReadyState, item} = this.props.portfolio;
        if (DetailReadyState === 'WORK_DETAIL_FETCHING') {
            return <Loader />;
        } else if (DetailReadyState === 'WORK_DETAIL_FETCHED') {
            return <PortfolioItemDetail />; // used to have this as this.props.children when the route was nested
        } else if (DetailReadyState === 'WORK_DETAIL_FETCH_FAILED') {
            browserHistory.push('/not-found');
        }
    }

    render() {
        return (
            <div id="interior-page">
                {this.renderPortfolioItemDetail(browserHistory)}
            </div>
        );
    }
}

function mapStateToProps(state) {
    return {
        portfolio: state.portfolio
    };
}
function mapDispatchToProps(dispatch) {
    return {
        dispatch: dispatch
    }
}

export default connect(mapStateToProps, mapDispatchToProps)(PortfolioDetail);

PortfolioItemDetail.js

import React, {Component} from 'react';
import {connect} from 'react-redux';
import Gallery from './gallery';

export default class PortfolioItemDetail extends React.Component {

    makeGallery(gallery) {
        if (gallery) {
            return gallery
                .split('|')
                .map((image, i) => {
                    return <li key={i}><img src={'/images/portfolio/' + image} alt="" /></li>
            })
        }
    }

    render() {
        const { item } = this.props.portfolio;

        return (
            <div className="portfolio-detail container-fluid">
                <Gallery
                    makeGallery={this.makeGallery.bind(this)}
                    item={item}
                />
            </div>
        );
    }
}

function mapStateToProps(state) {
    return {
        portfolio: state.portfolio
    };
}

export default connect(mapStateToProps)(PortfolioItemDetail);

gallery.js

import React, { Component } from 'react';
import { Link } from 'react-router';

const Gallery = (props) => {

    const {gallery, prev, next} = props.item;
    const prevButton = prev ? <Link to={'/portfolio/' + prev}><button className="button button-xs">Previous</button></Link> : '';
    const nextButton = next ? <Link to={'/portfolio/' + next}><button className="button button-xs">Next</button></Link> : '';

    return (
        <div>
            <ul className="gallery">
                {props.makeGallery(gallery)}
            </ul>
            <div className="next-prev-btns">
                {prevButton}
                {nextButton}
            </div>
        </div>
    );
};

export default Gallery;

New routes, based on Anoop's suggestion:

<Route component={App}>
    <Route path='/' component={Home}>
        <Route path="/index:hashRoute" component={Home} />
    </Route>
    <Route path="/portfolio/:slug" component={PortfolioDetail} />
    <Route path="*" component={NoMatch} />
</Route>
unleash.it
  • 309
  • 1
  • 2
  • 10

7 Answers7

3

Could not get to the bottom of this, but I was able to achieve my goals with ComponentWillRecieveProps:

componentWillReceiveProps(nextProps){
    if (nextProps.params.slug !== this.props.params.slug) {
        const {dispatch, params} = nextProps;
        PortfolioDetail.readyOnActions(dispatch, params, true);
    }
}

In other words, for whatever reason when I use React Router Link to link to a page with the SAME PARENT COMPONENT, it doesn't fire componentWillUnMount/componentWillMount. So I'm having to manually trigger my actions. It does work as I expect whenever I link to Routes with a different parent component.

Maybe this is as designed, but it doesn't seem right and isn't intuitive. I've noticed that there are many similar questions on Stackoverflow about Link changing the url but not updating the page so I'm not the only one. If anyone has any insight on this I would still love to hear it!

unleash.it
  • 309
  • 1
  • 2
  • 10
2

It's good to share the components code also. However, I tried to recreate the same locally and is working fine for me. Below is the sample code,

import { Route, Link } from 'react-router';
import React from 'react';
import App from '../components/App';

const Home = ({ children }) => (
  <div>
    Hello There Team!!!
    {children}
  </div>
);

const PortfolioDetail = () => (
  <div>
    <Link to={'/portfolio/previous-item'}>
      <button className="button button-xs">Previous</button>
    </Link>
    <Link to={'/portfolio/next-item'}>
      <button className="button button-xs">Next</button>
    </Link>
  </div>
);

const PortfolioItemDetail = () => (
  <div>PortfolioItemDetail</div>
);

const NoMatch = () => (
  <div>404</div>
);

module.exports = (
  <Route path="/" component={Home}>
    <Route path='/' component={Home}>
        <Route path="/index:hashRoute" component={Home} />
    </Route>
    <Route path="/portfolio" component={PortfolioDetail} />
    <Route path="/portfolio/:slug" component={PortfolioItemDetail} />
    <Route path="*" component={NoMatch} />
  </Route>
);
anoop
  • 3,229
  • 23
  • 35
  • Totally agree so I ust added the relevant parts of the component code. Thanks for the working example, but I'm not sure it matches my needs because I would like to not have to rerender the header/footer when changing to next/prev portfolio items. Still, in the spirit of getting it to work I tried it without nesing, similar to what you did (but no /portfolio route and targeting the parent component instead of the Item detail). It still works as before, but I still have no luck getting Link to remount the component. – unleash.it Aug 07 '16 at 22:06
  • It might also be worth mentioning that this is a universal app. It's working fine when the pages are linked to from a non /portfolio route (like Home) but I can't seem to link between portfolio pages without a refresh. Live example is here: https://jasongallagher.org – unleash.it Aug 07 '16 at 22:11
  • did you figure this out? I'm hitting the same issue. Is it because components have their own "route"? what can cause them to trigger and update? – dcsan Aug 18 '16 at 05:51
  • Not really, except the workaround below. Any state change should trigger an update but for some reason Link isn't always doing that. At some point I may switch to React Router Redux and see if that fixes it as I'm using Redux. – unleash.it Aug 18 '16 at 08:04
1

componentWillReceiveProps is the answer to this one, but it's a little annoying. I wrote a BaseController "concept" which sets a state action on route changes EVEN though the route's component is the same. So imagine your routes look like this:

<Route path="test" name="test" component={TestController} />
<Route path="test/edit(/:id)" name="test" component={TestController} />
<Route path="test/anything" name="test" component={TestController} />

So then a BaseController would check the route update:

import React from "react";

/**
 * conceptual experiment
 * to adapt a controller/action sort of approach
 */
export default class BaseController extends React.Component {


    /**
     * setState function as a call back to be set from
     * every inheriting instance
     *
     * @param setStateCallback
     */
    init(setStateCallback) {
        this.setStateCall = setStateCallback
        this.setStateCall({action: this.getActionFromPath(this.props.location.pathname)})
    }

    componentWillReceiveProps(nextProps) {

        if (nextProps.location.pathname != this.props.location.pathname) {
            this.setStateCall({action: this.getActionFromPath(nextProps.location.pathname)})
        }
    }

    getActionFromPath(path) {

        let split = path.split('/')
        if(split.length == 3 && split[2].length > 0) {
            return split[2]
        } else {
            return 'index'
        }

    }

    render() {
        return null
    }

}

You can then inherit from that one:

import React from "react"; import BaseController from './BaseController'

export default class TestController extends BaseController {


    componentWillMount() {
        /**
         * convention is to call init to
         * pass the setState function
         */
        this.init(this.setState)
    }

    componentDidUpdate(){
        /**
         * state change due to route change
         */
        console.log(this.state)
    }


    getContent(){

        switch(this.state.action) {

            case 'index':
                return <span> Index action </span>
            case 'anything':
                return <span>Anything action route</span>
            case 'edit':
                return <span>Edit action route</span>
            default:
                return <span>404 I guess</span>

        }

    }

    render() {

        return (<div>
                    <h1>Test page</h1>
                    <p>
                        {this.getContent()}
                    </p>
            </div>)
        }

}
chickenchilli
  • 3,358
  • 2
  • 23
  • 24
1

I got stuck on this also in React 16.

My solution was as follows:

componentWillMount() {
    const { id } = this.props.match.params;
    this.props.fetchCategory(id); // Fetch data and set state
}

componentWillReceiveProps(nextProps) {
    const { id } = nextProps.match.params;
    const { category } = nextProps;

    if(!category) {
        this.props.fetchCategory(id); // Fetch data and set state
    }
}

I am using redux to manage state but the concept is the same I think.

Set the state as per normal on the WillMount method and when the WillReceiveProps is called you can check if the state has been updated if it hasn't you can recall the method that sets your state, this should re-render your component.

Daniel Tate
  • 2,075
  • 4
  • 24
  • 50
0

I am uncertain whether it fixes the original problem, but I had a similar issue which was resolved by passing in the function callback () => this.forceUpdate() instead of this.forceUpdate.

Since no one else is mentioning it, I see that you are using onClick={this.forceUpdate}, and would try onClick={() => this.forceUpdate()}.

MattH
  • 121
  • 8
0

Try to import BrowserRouter instead of Router

import { Switch, Route, BrowserRouter as Router } from 'react-router-dom;

It worked for me after spending a couple of hours solving this issue.

0

I solved this by building '' custom component instead of '', and inside it I use in the method instead of :

import * as React from "react";
import {Navigate} from "react-router-dom";
import {useState} from "react";

export function ReactLink(props) {
    const [navigate, setNavigate] = useState(<span/>);
    return (
    <div style={{cursor: "pointer"}}
                onClick={() => setNavigate(<Navigate to={props.to}/>)}>
        {navigate}
        {props.children}
    </div>
}
Israel
  • 1,165
  • 11
  • 11