In a react-router (v4) app you will inevitably need to manage the URL. This is one of those easy-hard things that react-router provides a basic API for and leaves the rest of the implementation up to the user.
The sticking point is that react-router location.search
and location.hash
are stored as strings. Older versions of react-router would parse these values into a location.query
object that is no longer available.
If you need to work with query strings as objects you will need a library like query-string
. This will allow you to parse the location.search
and location.hash
strings as objects. You can then stringify query objects back into search strings and use them in a <Link />
or history.push()
call.
What libraries are we using?
yarn add \
react \
react-router-dom \
query-string \
redux \
react-redux \
prop-types
This is an overly simplistic example that shows the basics of working with a query object and pushing it into the history
using a <Link />
component. Normally you'd want to manage your query object somewhere other than the render
method of a component. Regardless, you can see below that it's quite simple to use props
to construct a query
object and stringify it. If the link is clicked, the new location (pathname
+ search
) will be pushed to history.
Note: for such a simple example you might not gain much from Query.stringify
. If you are passing a very simple search
there is a strong performance benefit to simply constructing the string manually. The case for using a query
is more obvious when you need to work with complex query data.
import React from 'react'
import PropTypes from 'prop-types'
import { Link } from 'react-router-dom'
import Query from 'query-string'
const My = ({ bar }) => {
const query = {
foo: bar
}
return (
<div>
<Link to={{
pathname: '/some-path'
search: Query.stringify(query) // becomes foo=bar
}}>Foo!</Link>
Hello!
</div>
)
}
My.propTypes = {
bar: PropTypes.string
}
export default My
There are times where you want to update a part of the search string and leave the rest of it as-is. To do this you need to Query.parse
the location.search
which will return a query
object. Then you can manipulate the query
object to suit your needs. Because parsing create a brand new object, you can safely mutate the query
. In order to update the location, you need to Query.stringify
the query
, which converts it back to a search string.
import React from 'react'
import PropTypes from 'prop-types'
import { Link, withRouter } from 'react-router-dom'
import Query from 'query-string'
const My = ({ bar, location }) => {
// we can parse the current search and extend it
const query = Query.parse(location.search) // pretend search has an existing value
query.foo = bar // it's safe to mutate this object
console.log(query) // => { foo: 'bar string', existing: true }
return (
<div>
<Link to={{
pathname: location.pathname,
search: Query.stringify(query) // becomes foo=bar&existing=true
}}>Foo!</Link>
Hello!
</div>
)
}
My.propTypes = {
bar: PropTypes.string
}
export default withRouter(My)
There are times when you want to have deeper programatic control over how your app's history is managed. In those cases a Link
is too simplistic and we need to turn to the history
object that withRouter
makes available.
This is a somewhat convoluted example. Below you can see that we're misusing react-redux connect
— our mapStateToProps
isn't actually reading anything from state
. We're using this construct because it's common that you'll want to populate your component with a mix of values that come from state
, ownProps
and location
. In those cases, it's sensible to do this work in mapStateToProps
, which keeps your components pure.
Note: following the react-redux conventions of managing state
and dispatch
separately is useful even if you're not using redux. In the case of react-router, you could consider location
the "url state" and history
the mechanism for "dispatching url actions". Keeping these concepts separated in your code will provide some value in terms of maintainability. You can see an example of a connect-like interface further below.
Below you can see a simple functional component that displays the value of foo
and faithfully calls onClick
. If you look in mapStateToProps
you can see that we're reading the value of foo
from the location
by parsing the location.search
string.
You can also see in mapDispatchToProps
that we're setting the location.search
programmatically using history.push
.
import React from 'react'
import PropTypes from 'prop-types'
import { compose } from 'redux'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
import Query from 'query-string'
const My = ({ foo, onClick }) => (
<div>
<span onClick={onClick}>Foo!</span>
I pity the {foo}
</div>
)
My.propTypes = {
foo: PropTypes.string,
onClick: PropTypes.string
}
// You can read query from the location
const mapStateToProps = (state, ownProps) => {
const { location } = ownProps
const query = Query.parse(location.search)
const { foo } = query
return {
foo // neato! foo comes from the location instead of redux
}
}
// You can push route changes into the history
const mapDispatchToProps = (dispatch, ownProps) => {
const { bar, location, history } = ownProps
return {
onClick: () => {
dispatch(setFoo(bar)) // we can tell redux about foo (optional)
const query = Query.parse(location.search)
query.foo = bar // add foo to the query
history.push({
pathname: location.pathname, // keeps us on the same "page"
search: Query.stringify(query) // becomes foo=bar&existing=true
})
}
}
}
const MyContainer = compose(
withRouter, // <-- makes history, location and match available in ownProps
connect(mapStateToProps, mapDispatchToProps)
)(My)
// We expect our container to receive a bar prop
MyContainer.propTypes = {
bar: PropTypes.string.isRequired
}
export default MyContainer
You can see in the above example that we're using compose
from redux to apply multiple higher-order-components to our <My />
component. The composition order is important and might change based on the use case.
Above we're setting withRouter
first, which means it gets applied second. This makes react-router's location
, history
and match
available within ownProps
. This is useful for cases where we need to work with both redux and react-router within mapDispatchToProps
and mapStateToProps
. Because of how react-redux connect
works, it will create some issues with "blocked updates".
The recommended solution is to compose withRouter
first and connect
second, as shown above.
Note: reversing the order will cause troubles. You can see below that composing withRoute
second makes the location
changes invisible to connect
. This will prevent location changes from propagating.
// Good! withRouter first
const MyContainer = compose(
withRouter, // <-- unblocks
connect(mapStateToProps, mapDispatchToProps)
)(My)
// Whoops! withRoute second
const MyContainer = compose(
connect(mapStateToProps, mapDispatchToProps),
withRouter // <-- still blocked
)(My)
This is a wrapper that implements a similar interface to react-redux connect
for situations where you don't need redux but you still want to encapsulate your router manipulation logic in a container and keep your component pure.
Below you can see that it's got the same interface as react-redux connect
except that you use mapLocationToProps
and mapHistoryToProps
instead of mapStateToProps
and mapDispatchToProps
respectively.
Why would you do this? The only real benefit is it allows you to extract your react-router logic from your component and keep it in a connect-like container component.
import React from 'react'
import PropTypes from 'prop-types'
import connectRouter from 'connect-router'
import Query from 'query-string'
const My = ({ foo, onClick }) => (
<div>
<span onClick={onClick}>Foo!</span>
I pity the {foo}
</div>
)
My.propTypes = {
foo: PropTypes.string,
onClick: PropTypes.string
}
const mapMatchToProps = undefined
// You can read query from the location
const mapLocationToProps = (location, ownProps) => {
const query = Query.parse(location.search)
const { foo } = query
return {
foo // neato! foo comes from the location
}
}
// You can push route changes into the history
const mapHistoryToProps = (history, ownProps) => {
const { bar, location } = ownProps // location is available in ownProps
return {
onClick: () => {
const query = Query.parse(location.search)
query.foo = bar
history.push({
pathname: location.pathname, // keeps us on the same page
search: Query.stringify(query) // becomes foo=bar&existing=true
})
}
}
}
const MyContainer = connectRouter(mapMatchToProps, mapLocationToProps, mapHistoryToProps)(My)
// We expect our container to receive a bar prop
MyContainer.propTypes = {
bar: PropTypes.string.isRequired
}
export default MyContainer
Here's a "probably-working" connectRouter
HOC.
import React, { PureComponent } from 'react'
import { withRouter } from 'react-router-dom'
const defaultMergeProps = (...allProps) => Object.assign.apply(Object, allProps)
const connectRouter = (mapMatchToProps, mapLocationToProps, mapHistoryToProps, mergeProps = defaultMergeProps) => WrappedComponent => {
class ConnectRouter extends PureComponent {
render() {
const { props } = this
const { history, location, match } = props
let matchProps
let locationProps
let historyProps
if (typeof mapMatchToProps === 'function') {
const matchProps = mapMatchToProps(match, props)
}
if (typeof mapLocationToProps === 'function') {
const locationProps = mapLocationToProps(location, props)
}
if (typeof mapHistoryToProps === 'function') {
const historyProps = mapHistoryToProps(history, props)
}
const finalProps = mergeProps({}, props, matchProps, locationProps, historyProps)
// prevent history, location and match from being passed to the wrapped component
if (matchProps.history === undefined && locationProps.history === undefined && historyProps.history === undefined) {
finalProps.history = undefined
}
if (matchProps.location === undefined && locationProps.location === undefined && historyProps.location === undefined) {
finalProps.location = undefined
}
if (matchProps.match === undefined && locationProps.match === undefined && historyProps.match === undefined) {
finalProps.match = undefined
}
return (<WrappedComponent {...finalProps} />)
}
}
return withRouter(ConnectRouter)
}
export default connectRouter