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 happened 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
, androutes
to props injected bywithRouter
and to properties oncontext.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.
- 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
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
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
<Route>
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
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
render((
<Provider store={store}>
<Router>
{renderRoutes(routes)}
</Router>
</Provider>
), document.querySelector('#react-view'))
And on server side, load it like this
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
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
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.
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.
Originally posted at Envato webuild blog
Hi! I am a robot. I just upvoted you! I found similar content that readers might be interested in:
https://webuild.envato.com/blog/a-real-word-story-of-upgrading-react-router-to-v4-in-an-isomorphic-app/
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
It's me!
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
Congratulations @fraserxu! You have completed some achievement on Steemit and have been rewarded with new badge(s) :
You made your First Comment
Click on any badge to view your own Board of Honor on SteemitBoard.
For more information about SteemitBoard, click here
If you no longer want to receive notifications, reply to this comment with the word
STOP
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
Congratulations @fraserxu! You have received a personal award!
1 Year on Steemit
Click on the badge to view your Board of Honor.
Do not miss the last post from @steemitboard:
SteemitBoard World Cup Contest - The semi-finals are coming. Be ready!
Participate in the SteemitBoard World Cup Contest!
Collect World Cup badges and win free SBD
Support the Gold Sponsors of the contest: @good-karma and @lukestokes
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
Congratulations @fraserxu! You received a personal award!
You can view your badges on your Steem Board and compare to others on the Steem Ranking
Vote for @Steemitboard as a witness to get one more award and increased upvotes!
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit