A real world story of upgrading react-router to v4 in an isomorphic app

While working on the new Envato Market Shopfront app, the team agreed to always keep all the dependencies in the project up to date. Sometimes it was just straightforward patch or minor version upgrade, but sometimes it could also be breaking changes that need a whole lot of thought. The upgrade to react-router v4 happended to be a good example.

The story

First a little bit background on our current stack (and version):

  • React for the view layer (v15.4.2)
  • Redux for state management (v3.5.2)
  • React Router for routing (v2.8.1)
  • Node.js for server-side rendering and providing a simple proxy layer to our APIs (v6.10.1)
  • Webpack (v2.3.3) and Babel for bundling JavaScript for server and browser

My original plan was to upgrade all the dependencies in one pull request. But when it comes to the React related package families, things start to get out of control. For those who are using React in their project already, you may have already heard about the latest changes to React v15.5.0. 

The biggest change is that we’ve extracted React.PropTypes and React.createClass into their own packages.

This means, for every single component that is using those two packages or methods, it will have to be updated to using the new packages to get rid of all the deprecation warnings. Luckily, the React team always provide nice codemod with react-codemod to automatically migrate the code. 

But what about third party React related modules? If you’ve chosen your project’s packages wisely and with a little luck, the package author would have already released a new version to support the latest release of React and, even if that’s not the case, this might be a good opportunity to give back by sending a pull request to the repo.

Everything went pretty smoothly until it came to upgrade React Router. We are currently on v2.8.1, do we want to upgrade to v3 or v4 now?

Considering all the changes we’ve already made to the other React packages, I thought that there might be too many changes in one pull request, so in the end I decide to try to only update to v3 (as I’ve heard React Router v4 has changed dramatically since the previous version) in a separate pull request.

It seems to me that the biggest change from v2 to v3 for React Router is withRouter according to the change logs. 

Add params, location, and routes to props injected by withRouter and to properties on context.router

It turns out to be a big problem for us because we depends on the location object heavily for critical search query filters, SEO and other stuff. Previously, location was not injected by withRouter, we were passing a modified version of it from the very top page level down to component which need access to location

And not by coincidence, those components also use withRouter to do props.router.push for page transitions (router here is injected by withRouter to component props). The newer version however providing that, we will now have lots of conflicts regarding the location object.

Because the code is heavily dependent on React Router and we can’t change the internal API provided by it, we can only modify or rename the location we are passing down which is not a small amount of work.

Considering the amount of work from v2 to v3, why not upgrade to v4 directly? I decided to have a try.

The how

Before reading this I highly recommend reading the migrating guide from the official Github Repo first.

The first change I made is to install the new react-router-dom and update all the references in the code from react-router to react-router-dom. For those who don’t know what react-router-dom is and the difference between them, short answer is that react-router includes both react-router-dom and react-router-native. For a web based project react-router-dom is usually what you need.

1
2
- import { withRouter } from 'react-router'
+ import { withRouter } from 'react-router-dom'

Another big difference is that instead of having a centralised route configuration for your application and rendering children based on router, now you can define a child component as a normal one inside the component where you need to render content based on current location.

However this doesn’t really work for us because we are doing isomorphic rendering. The key for achieving isomorphic rendering is the ability to pre-fetch data before calling React.renderToString so the content(HTML) you sent to browser will have the required data.

In the previous version, we normally have a central routes config like this

1
2
3
4
5
6
7
8
9
10
export default (
  <Route path='/' component={AppContainer}>
    <IndexRoute component={SearchPageContainer} />
    <Route path='category/*' component={SearchPageContainer} />
    <Route path='tags/:tags' component={SearchPageContainer} />
    <Route path='attributes/:key/:value' component={SearchPageContainer} />
    <Route path='search' component={SearchPageContainer} />
    <Route path='*' component={NotFoundPageContainer} />
  </Route>
)

On the server side, when the request comes in, we can have an express.js middleware like this to handle and render the content

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export default (req, res) => {
  match({
    routes,
    location: req.url
  }, (error, redirectLocation, renderProps) => {
    // here we assume we have defined a `loadData` static method on the component where we want to pre-fetching data
    const prefetchingRequests = renderProps.components.map(component => {
      if (component && component.loadData) {
        return component.loadData(renderProps)
      }
    })
    Promise.all(prefetchingRequests)
      .then(prefetchedData => {
        const HTML = React.renderToString(<App data={prefetchedData}></App>)
        res.send(HTML)
      })
  })
}

What about v4?

In v4, there is no centralized route configuration. Anywhere that you need to render content based on a route, you will just render a component.

There’s no central routes config anymore, how do we co-locate the static loadData method on the render component tree?

Luckily there is someone already doing this for us! There’s a package named react-router-config from the react-router team. 

To achieve the same purpose, now we just have to adjust our routes config into something like this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export default [
  {
    path: '/',
    component: AppContainer,
    routes: [
      {
        path: '/category/*',
        component: SearchPageContainer
      },
      {
        path: '/tags/:tags',
        component: SearchPageContainer
      },
      {
        path: '/attributes/:key/:value',
        component: SearchPageContainer
      },
      {
        path: '/search',
        component: SearchPageContainer
      },
      {
        path: '/*',
        component: NotFoundPageContainer
      }
    ]
  }
]

And in the client side, load it like this

1
2
3
4
5
6
7
render((
  <Provider store={store}>
    <Router>
      {renderRoutes(routes)}
    </Router>
  </Provider>
), document.querySelector('#react-view'))

And on server side, load it like this

1
2
3
4
5
6
7
8
9
10
const componentHTML = renderToString(
  <Provider store={store}>
    <StaticRouter
      location={req.url}
      context={context}
    >
      {renderRoutes(routes)}
    </StaticRouter>
  </Provider>
)

With this setup in place, we are now ready to co-locate fetch calls, we can use the matchRotues provided by the package 

1
2
3
4
5
6
7
8
9
10
11
12
const prefetchingRequests = matchRoutes(routes, parsedUrl.pathname)
  .map(({ route, match }) => {
    return route.component.loadData
      ? route.component.loadData(match)
      : Promise.resolve(null)
  })

Promise.all(prefetchingRequests)
  .then(prefetchedData => {
    const HTML = React.renderToString(<App data={prefetchedData}></App>)
    res.send(HTML)
  })

And 💥, we now have server side data pre-fetch working with React Router v4. The last thing we need to fix is client side data fetching. This happens when user switches route inside the browser, we will also need to trigger the same requests to load new data to render new content.

In the previous version, we can use browserHistory.listen to watch client router change and trigger the network request

1
2
3
4
5
browserHistory.listen(location => {
  match({ routes, location }, (error, redirectLocation, renderProps) => {
   // same as what we did for server side
  })
})

This could still work with the new version, but we can also follow the example given in react-router-config repo to create a special component, and use withRouter to attach the location object to the compoent props, then we can use componentWillReceiveProps to listen on location change and trigger the network request call.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
componentWillReceiveProps(nextProps) {
  const navigated = nextProps.location !== this.props.location
  const { routes } = this.props
  if (navigated) {
    // save the location so we can render the old screen
    const prefetchingRequests = matchRoutes(routes, window.location.pathname)
      .map(({ route, match }) => {
        return route.component.loadData
          ? route.component.loadData(match)
          : Promise.resolve(null)
      })
    Promise.all(promiseRequests)
      .then((prefetchedData) => {
        // do things with new data
      })
  }
}

Another benefit of using componentWillReceiveProps over browserHistory.listen is that you have a context of the previous location and the current location so you can implement shouldFetchNewData to prevent making expensive network requests.

Conclusion

Voila! That was pretty much what we needed to do to upgrade an isomorphic React app from React Router v2 to v4. I’ve definitely learned a lot from it:

  • It was probably a bad idea to depend so much on a routing library so deeply nested in application component tree. What we should probably do next is make the location part of the redux store—this way, the next time the location object changes, we simply update it in the redux store without having to modify things all over the code base.
  • Some of you who read this article may be wondering why am I doing the upgrade, same question for me when I was in the middle of doing this. Is it just because we want to keep everything up to date? I’m not sure. Maybe not. I was able to figure out that there are still quite a lot of places in our code which could be improved.

Last but not least, I wrote this article because by the time I went to do the upgrade, I couldn’t find any existing example I could refer to, and I hope you may find this one helpful.