Skip to main content

Query parameters in Gatsby

18 August, 2020

Recently, I just finished adding all of the blog functionality I needed to this website. While I ran into a host of new to me problems while building the blog, most of it was easily solved using docs & questions others had already written & asked.

Problem

One problem, however, had little in the way of answers that I could find: how to best handle URL Query Parameters with Reach Router. I found some good discussion about the issue using React Router, but since I'm using Gatsby, I'm still stuck with Reach's version, for now.

It appears that Reach is designed for using parameters in the URL path directly (via URL Parameters), but that didn't feel right for my use case (simply toggling the sort direction on my posts list). The best ideas I found suggested using the search property on Reach Router's useLocation() hook to directly get the query, & parsed it using a third party library. I wanted to limit third party libraries unless they felt absolutely necessary, so after some more Googling, I found URLSearchParams.get() on MDN (RIP) to be very simple & easy to use.

Solution

Putting the idea from StackOverflow and URLSearchParams together started with a simple function:

import { useLocation } from '@reach/router'

function getQueryParam (query: string): string | null {
  const location = useLocation()
  const search = new URLSearchParams(location.search)

  return search.get(query)
}

While this worked perfectly, I found it a little cumbersome to have to think in terms of parsing a query parameter every time I wanted to use this; plus, I wanted to be able to just use the value returned like I would any other state value in React. As another StackOverflow answer suggested, I thought writing a custom hook might be a good place to start.

While building this hook, I had three goals in mind:

  1. It should return a value based on the current value of a desired query parameter
  2. It should also return a function that updates the query parameter and the previously returned value, just like a setter in React's useState hook
  3. It should optionally accept a default value in case the query parameter isn't set yet

Since I wanted my useQueryParam hook to closely resemble React's useState, I figured it best to start there:

import { useLocation } from '@reach/router'

function getQueryParam (query: string): string | null {
  const location = useLocation()
  const search = new URLSearchParams(location.search)

  return search.get(query)
}

const useQueryParam(query: string) => {  const [value, setValue] = React.useState(    getQueryParam(query) // but this still needs parsed  )  const update = (newValue) => {    setValue(newValue)    // ... something to update query value here  }  return [ value, update ]}

While this worked to grab a query parameter's value, it's missing the logic to update the parameter, as well as how to parse the string returned by getQueryParam. Parsing had a pretty simple solution, splitting the comma separated string using String.prototype.split(). Additionally, it guards against a null value since URLSearchParams returns null if the query isn't set.

// ...

const useQueryParam = (
  query: string,
  defaultValue: string[] = ['']
) => {
  const parseString = (    str: string | null,    default: string[]  ): string[] => {    if (!str) return default    return str.split(',')  }
  const [value, setValue] = React.useState(
    parseString(getQueryParam(query), defaultValue)  )

  // ...

  return [ value, update ]
}

Updating the actual query parameter in the browser's location object turned out to be pretty easy, using Reach Router's useNavigate() hook:

import { useLocation, useNavigate } from '@reach/router'

// ...

const useQueryParam(query: string) => {
  const navigate = useNavigate()
  const parseString = (
    str: string | null,
    default: string[]
  ): string[] => {
    if (!str) return default

    return str.split(',')
  }

  const [value, setValue] = React.useState(
    parseString(getQueryParam(query), defaultValue)
  )

  const update = (newValue) => {
    setValue(newValue)
    // join the values back into a comma-separated    // string and use `navigate` to update location    navigate(`?${query}=${newValue.join(',')}`)  }

  return [ value, update ]
}

This worked pretty well, but I quickly found a bug: when a user clicked the back button in their browser, the URL updates, but the returned value did not. Fixing this took some research, but I found my answer in yet another part of Reach Router's API: globalHistory.listen().

// ...

const useQueryParam(query: string) => {
  const navigate = useNavigate()

  // ...

  const update = (newValue) => {
    setValue(newValue)
    // join the values back into a comma-separated
    // string and use `navigate` to update location
    navigate(`?${query}=${newValue.join(',')}`)
  }

  // forward & back button behavior  // inside a useEffect hook to allow lifecycle updates to clean  // up listeners on component unmount  React.useEffect(() => {    const historyEventCallback = ({      action,      location: newLocation    }) => {      // back and forward navigation is sent as a 'POP' action      if (action === 'POP') {        setValue(          parseString(            getQueryParam(query)))      }    }    return globalHistory.listen(historyEventCallback)  }, [])
  return [ value, update ]
}

I found it easiest to move the useLocation call into the useQueryParam hook to co-locate calls to Reach Router's hooks & allow getQueryParam to be passed a location instead of relying on it to get the correct location on its own. With that complete, the final result ended up as the following:

import { useLocation, useNavigate } from '@reach/router'

const getQueryParam = (location: WindowLocation, query: string) => {  const search = new URLSearchParams(location.search)
  return search.get(query)
}

const useQueryParam(query: string) => {
  const location = useLocation()  const navigate = useNavigate()

  const parseString = (
    str: string | null,
    default: string[]
  ): string[] => {
    if (!str) return default

    return str.split(',')
  }

  const [value, setValue] = React.useState(
    parseString(getQueryParam(query), defaultValue)
  )

  const update = (newValue) => {
    setValue(newValue)
    // join the values back into a comma-separated
    // string and use `navigate` to update location
    navigate(`?${query}=${newValue.join(',')}`)
  }

  // forward & back button behavior
  // inside a useEffect hook to allow lifecycle updates to clean
  // up listeners on component unmount
  React.useEffect(() => {
    const historyEventCallback = ({
      action,
      location: newLocation
    }) => {
      // back and forward navigation is sent as a 'POP' action
      if (action === 'POP') {
        setValue(
          parseString(
            getQueryParam(newLocation, query)))      }
    }

    return globalHistory.listen(historyEventCallback)
  }, [])

  return [ value, update ]
}

Conclusion

Overall, this seems to be working great so far, meeting all three of my original goals & with only the one (solved) bug. My biggest complaint is that the forward & back button behavior was really hard to test. This is because the best way I've found to test any component that uses any location-aware logic (i.e. anything with the useLocation() hook) is to wrap it in a LocationProvider & it appears that the history source here doesn't provide any good way of simulating a user clicking the forward or back buttons. I ended up stubbing out globalHistory's listen() method using sinon, then manually calling the callback I gave it by reaching into the stubbed out method using the SinonStub.args directly. While pretty hacky, it worked well enough for now.

~~~

Do you have any questions or comments about anything? I'd love to hear from you! Send me a message using the form or any of the social media platforms below, or just send me an email at hello@andrew-chang-dewitt.dev