Skip to content

Instantly share code, notes, and snippets.

@heygrady
Last active August 22, 2021 06:06
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save heygrady/c5d29cdca753520ded4f38fccb23602c to your computer and use it in GitHub Desktop.
Save heygrady/c5d29cdca753520ded4f38fccb23602c to your computer and use it in GitHub Desktop.
Managing location in react-router

Managing URL State

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

Using Link

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

Adding values to location.search

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)

Using history with redux

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

Compose order

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)

Imaginary connectRouter HOC

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

connectRouter implementation

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment