Using React Router searchParams to manage filter state for a list

Gareth Cronin
5 min readFeb 8, 2023

--

I’ve written a couple of stories about the apps on my chosen web app stack. One of my examples is a progressive web app called “Bar Tool”. It’s a mobile-first app that helps mixology enthusiasts like me riff on classic drink recipes. Bar Tool uses my standard tool-kit with Google Material React bindings at the front end.

A core part of the Bar Tool user experience is displaying a list of recipes that the user can filter by tag, ingredient, name, and a couple of other toggles. I was storing the selected filter criteria in browser local storage, which worked OK, but didn’t take advantage of a couple of useful built-in browser features. I couldn’t share a link including filter criteria to another user, and changing the filters didn’t built up a browser history so that the browser back and forward buttons would respect changing searches. Back in good old Web 1.0 days, it was common to use URL query string parameters to define criteria for filters, so I decided to see how I might do that in React. E.g. if the user selected the “Stir” library tag and their “Favourite” user tag, I wanted the URL to end with:

?tags=Stir&usertags=Favourite

Conversely, if the user clicks a link with the query string above, then I wanted my app to apply the filter that it represents.

React Router 6 has a useSearchParams hook for this purpose. It provides a method for reading the parameters and one for setting them. The set method works like the useNavigate hook by manipulating the URL.

The existing Bar Tool logic read from a series of useState hook values to build a filter object that became JSON passed to the back-end API in a GET request. The UI components directly modified this state as the user changed the criteria to filter the list. I decided to change this so that the UI components would modify the search parameters in the URL, which would in turn trigger logic to parse the search parameters and then set the state variables.

Putting search params in the middle

Of course it’s not quite as simple as that.

Dealing with parameters with multiple values

HTTP allows multiple values for a URL parameter. They are expressed in a query string as multiple instances like this:?tags=Stir&tags=Vintage

The React Router search params hook presents the parameters as a URLSearchParams object. This is a standard web type documented at MDN. The object has get(<key>) and getAll(<key>) methods and an entries() method which returns an array of keys and values where the key can repeat for multiple parameter values, e.g. [["tags","Stir"]["tags","Vintage"]].

Bar Tool’s API expects an object in map style with one key and the values in an array, e.g. {"tags":["Stir","Vintage"]}.

I didn’t want to change the API, so I ended up creating a bunch of convenience methods to translate between URLSearchParams and the map style. Here’s an example:

/**
* Search params are held as an array of two elements arrays where member 0 is the key and member 1 is value
* This function extracts the values from the array and returns an object with the key as the property name and the value as the property value
* Where a key has multiple values, the value is an array of values
*
* @param {URLSearchParams} searchParams
*/
export function extractExistingParams(searchParams) {
const entries = Array.from(searchParams.entries());
return entries.reduce((acc, a) => ((acc[a[0]] = acc[a[0]] || []).push(a[1]), acc), {});
}

The query string also gets messy, leaving a key with an empty value if the last multi-value parameter value is removed, so there’s also this one:

/**
* Remove a value from an existing parameter where the parameter can occur multiple times
* If the value is the last value, the parameter is removed
*
* @param {URLSearchParams} searchParams
* @param {string} key
* @param {string} value
*/
export function removeExistingParamsArrayValue(searchParams, key, value) {
const existingParams = extractExistingParams(searchParams);
if (existingParams[key]) {
existingParams[key] = existingParams[key].filter(v => v !== value);
}
if (existingParams[key].length === 0) {
delete existingParams[key];
}
return existingParams;
}

Modifying search parameters from UI components

For each UI component, the event handler reads the current searchParams and updates them. E.g. this one handles a change to the sort field:

const handleSort = (sortField) => {
setSearchParams({ ...searchParams, sort: sortField });
};

The more complex cases use the utility methods I described above, e.g.:

onClick={() => {
if (selectedTags.includes(tag)) {
setSearchParams(removeExistingParamsArrayValue(searchParams, 'tags', tag));
}
else {
setSearchParams(augmentExistingParams(searchParams, 'tags', tag));
}
}}

Updating the filter state

Updating the filter state variables that become the query to the API is a matter of listening for changes to the searchParams from the hook.

React.useEffect(() => {

if (searchParams.get('tags')) {
setSelectedTags(searchParams.getAll('tags'));
}
else {
setSelectedTags([]);
}

if (searchParams.get('userTags')) {
setSelectedUserTags(searchParams.getAll('userTags'));
}
else {
setSelectedUserTags([]);
}
if (searchParams.get('sort')) {
setSortField(searchParams.get('sort'));
}
else {
setSortField('name');
}

//... etc...

setSearchParamsLoaded(true);
storeItem('searchParams', Array.from(searchParams.entries()));
}, [searchParams]);

Gotchas

You’ll notice a couple of mysterious lines at the end of the code above. One is setting a variable to say that the search params have been synced into the state variables and the second one is setting a browser local storage value.

The former is required because another useEffect is listening for changes to the filter variables and its job is to make the query to the API. I discovered that a useEffect is always called at least twice: once when the component is first loaded, and then subsequently if any of the variables in the list of dependencies changes. I hit a timing problem where the query to grab the data from the API would be called with empty parameters when the component loaded, and called again when the searchParams had been updated. Because the call with no parameters returned a lot more data, it would return later than the call with the correct parameters, obliterating my list! That one took a while to troubleshoot.

The local storage value is set so that I can build a link with the most recently used filter criteria included in menu navigation components. This method is called to create a “back to the recipes” type link in the menu bar:

const createRecipesLink = () => {
return getItem('searchParams') ?
{ pathname: '/recipes', search: `?${createSearchParams(getItem('searchParams'))}` } :
'/recipes';
};

Conclusion

There we have it. Now I can share URLs that include a filter, and the back and forward buttons can be used to follow a breadcrumb trail of filters.

--

--

Gareth Cronin

Technology leader in Auckland, New Zealand: start-up founder, father of two, maker of t-shirts and small software products